Initial commit: WordPress wp-content (themes, plugins, languages)
- Theme: momentry (custom theme with REST API routes) - Plugins: code-snippets (contains all API proxies) - Languages: zh_TW translations - Excludes: cache, backups, uploads, logs
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { isLicensed } from '../../utils/screen'
|
||||
import { isCondition } from '../../utils/snippets/snippets'
|
||||
import { Badge } from '../common/Badge'
|
||||
import { Button } from '../common/Button'
|
||||
import { useSnippetForm } from '../../hooks/useSnippetForm'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export interface ConditionModalButtonProps {
|
||||
setIsDialogOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const ConditionModalButton: React.FC<ConditionModalButtonProps> = ({ setIsDialogOpen }) => {
|
||||
const { snippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
const hasCondition = 0 !== snippet.conditionId
|
||||
|
||||
return (
|
||||
<div className={classnames('conditions-editor-open block-form-field', hasCondition ? 'has-condition' : 'no-condition')}>
|
||||
{isCondition(snippet) ? null
|
||||
: <>
|
||||
<h4>
|
||||
{__('Conditions', 'code-snippets')}
|
||||
<Badge name="beta" small>{__('beta', 'code-snippets')}</Badge>
|
||||
{!isLicensed() && <Badge name="pro" small>{__('Pro', 'code-snippets')}</Badge>}
|
||||
</h4>
|
||||
|
||||
<Button large disabled={isReadOnly} onClick={() => setIsDialogOpen(true)}>
|
||||
<Badge name="cond" small />
|
||||
{hasCondition
|
||||
? __('Edit Conditions', 'code-snippets')
|
||||
: __('Add Conditions', 'code-snippets')}
|
||||
</Button>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { Spinner } from '@wordpress/components'
|
||||
import { isRTL } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../hooks/useSnippetForm'
|
||||
import { isNetworkAdmin } from '../../utils/screen'
|
||||
import { isCondition } from '../../utils/snippets/snippets'
|
||||
import { ConditionModalButton } from '../ConditionModal/ConditionModalButton'
|
||||
import { SnippetLocationInput } from '../SnippetForm/fields/SnippetLocationInput'
|
||||
import { Notices } from '../SnippetForm/page/Notices'
|
||||
import { ShortcodeInfo } from './actions/ShortcodeInfo'
|
||||
import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings'
|
||||
import { ExportButtons } from './actions/ExportButtons'
|
||||
import { SubmitButtons } from './actions/SubmitButtons'
|
||||
import { ActivationSwitch } from './controls/ActivationSwitch'
|
||||
import { DeleteButton } from './actions/DeleteButton'
|
||||
import { PriorityInput } from './controls/PriorityInput'
|
||||
import { RTLControl } from './controls/RTLControl'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export interface EditorSidebarProps {
|
||||
setIsUpgradeDialogOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialogOpen }) => {
|
||||
const { snippet, isWorking } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div className="snippet-editor-sidebar">
|
||||
<div className="box">
|
||||
{snippet.id && !isCondition(snippet) ? <ActivationSwitch /> : null}
|
||||
|
||||
{isNetworkAdmin() ? <MultisiteSharingSettings /> : null}
|
||||
|
||||
{isRTL() ? <RTLControl /> : null}
|
||||
|
||||
<ConditionModalButton setIsDialogOpen={setIsUpgradeDialogOpen} />
|
||||
<SnippetLocationInput />
|
||||
<ShortcodeInfo />
|
||||
<PriorityInput />
|
||||
|
||||
{snippet.id
|
||||
? <div className="row-actions visible inline-form-field">
|
||||
<ExportButtons />
|
||||
<DeleteButton />
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<p className="submit">
|
||||
<SubmitButtons />
|
||||
{isWorking ? <Spinner /> : ''}
|
||||
</p>
|
||||
|
||||
<Notices />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { addQueryArgs } from '@wordpress/url'
|
||||
import React, { useState } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useRestAPI } from '../../../hooks/useRestAPI'
|
||||
import { Button } from '../../common/Button'
|
||||
import { ConfirmDialog } from '../../common/ConfirmDialog'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
|
||||
export const DeleteButton: React.FC = () => {
|
||||
const { snippetsAPI } = useRestAPI()
|
||||
const { snippet, setIsWorking, isWorking, handleRequestError } = useSnippetForm()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
id="delete-snippet"
|
||||
className="delete-button"
|
||||
disabled={isWorking}
|
||||
onClick={() => {
|
||||
setIsDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
{__('Delete', 'code-snippets')}
|
||||
</Button>
|
||||
|
||||
<ConfirmDialog
|
||||
open={isDialogOpen}
|
||||
title={__('Delete?', 'code-snippets')}
|
||||
confirmLabel={__('Delete', 'code-snippets')}
|
||||
confirmButtonClassName="is-destructive"
|
||||
onCancel={() => setIsDialogOpen(false)}
|
||||
onConfirm={() => {
|
||||
setIsDialogOpen(false)
|
||||
setIsWorking(true)
|
||||
|
||||
snippetsAPI.delete(snippet)
|
||||
.then(() => {
|
||||
setIsWorking(false)
|
||||
window.location.replace(addQueryArgs(window.CODE_SNIPPETS?.urls.manage, { result: 'deleted' }))
|
||||
})
|
||||
.catch((error: unknown) => handleRequestError(error, __('Could not delete snippet.', 'code-snippets')))
|
||||
}}
|
||||
>
|
||||
<p style={{ marginBlockStart: 0 }}>
|
||||
{__('You are about to delete this snippet.', 'code-snippets')}{' '}
|
||||
{__('Are you sure?', 'code-snippets')}
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useRestAPI } from '../../../hooks/useRestAPI'
|
||||
import { Button } from '../../common/Button'
|
||||
import { downloadSnippetExportFile } from '../../../utils/files'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import type { Snippet } from '../../../types/Snippet'
|
||||
import type { SnippetsExport } from '../../../types/schema/SnippetsExport'
|
||||
|
||||
interface ExportButtonProps {
|
||||
name: string
|
||||
label: string
|
||||
makeRequest: (snippet: Snippet) => Promise<SnippetsExport | string>
|
||||
}
|
||||
|
||||
const ExportButton: React.FC<ExportButtonProps> = ({ name, label, makeRequest }) => {
|
||||
const { snippet, isWorking, setIsWorking, handleRequestError } = useSnippetForm()
|
||||
|
||||
const handleClick = () => {
|
||||
setIsWorking(true)
|
||||
|
||||
makeRequest(snippet)
|
||||
.then(response => downloadSnippetExportFile(response, snippet))
|
||||
// translators: %s: error message.
|
||||
.catch((error: unknown) => handleRequestError(error, __('Could not download export file.', 'code-snippets')))
|
||||
.finally(() => setIsWorking(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<Button name={name} onClick={handleClick} disabled={isWorking}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const ExportButtons: React.FC = () => {
|
||||
const { snippetsAPI } = useRestAPI()
|
||||
|
||||
return (
|
||||
<div className="snippet-export-buttons">
|
||||
<ExportButton
|
||||
name="export_snippet"
|
||||
label={__('Export', 'code-snippets')}
|
||||
makeRequest={snippetsAPI.export}
|
||||
/>
|
||||
|
||||
{window.CODE_SNIPPETS_EDIT?.enableDownloads
|
||||
? <ExportButton
|
||||
name="export_snippet_code"
|
||||
label={__('Export Code', 'code-snippets')}
|
||||
makeRequest={snippetsAPI.exportCode}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react'
|
||||
import { CheckboxControl, ExternalLink, Modal } from '@wordpress/components'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { Button } from '../../common/Button'
|
||||
import { CopyToClipboardButton } from '../../common/CopyToClipboardButton'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
type ShortcodeAtts = Record<string, unknown>
|
||||
|
||||
const buildShortcodeTag = (tag: string, atts: ShortcodeAtts): string =>
|
||||
`[${[
|
||||
tag,
|
||||
...Object.entries(atts)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.map(([att, value]) =>
|
||||
'boolean' === typeof value ? att : `${att}=${JSON.stringify(value)}`)
|
||||
].filter(Boolean).join(' ')}]`
|
||||
|
||||
const SHORTCODE_TAG = 'code_snippet'
|
||||
|
||||
interface ShortcodeOptions {
|
||||
php: boolean
|
||||
format: boolean
|
||||
shortcodes: boolean
|
||||
}
|
||||
|
||||
interface CheckboxListProps<T extends string> {
|
||||
options: T[]
|
||||
checked: Record<T, boolean>
|
||||
disabled: boolean
|
||||
setChecked: Dispatch<SetStateAction<Record<T, boolean>>>
|
||||
optionLabels: Partial<Record<T, string>>
|
||||
optionDescriptions: Partial<Record<T, string>>
|
||||
}
|
||||
|
||||
const CheckboxList = <T extends string>({
|
||||
options,
|
||||
checked,
|
||||
disabled,
|
||||
setChecked,
|
||||
optionLabels,
|
||||
optionDescriptions
|
||||
}: CheckboxListProps<T>) =>
|
||||
<ul>
|
||||
{options.map(option =>
|
||||
<li key={option}>
|
||||
<CheckboxControl
|
||||
label={optionLabels[option]}
|
||||
help={optionDescriptions[option]}
|
||||
checked={checked[option]}
|
||||
disabled={disabled}
|
||||
onChange={value =>
|
||||
setChecked(previous => ({ ...previous, [option]: value }))}
|
||||
/>
|
||||
</li>)}
|
||||
</ul>
|
||||
|
||||
const ShortcodeDescription = () =>
|
||||
<p className="description">
|
||||
{__('Copy the below shortcode to insert this snippet into a post, page, or other content.', 'code-snippets')}{'\n'}
|
||||
{__('You can also use the Classic Editor button, Block editor (Pro) or Elementor widget (Pro).', 'code-snippets')}{'\n'}
|
||||
|
||||
<ExternalLink
|
||||
href={__('https://codesnippets.pro/doc/inserting-content-snippets/', 'code-snippets')}
|
||||
>
|
||||
{__('Learn more', 'code-snippets')}
|
||||
</ExternalLink>
|
||||
</p>
|
||||
|
||||
const OPTION_LABELS: Record<keyof ShortcodeOptions, string> = {
|
||||
php: __('Evaluate PHP code', 'code-snippets'),
|
||||
format: __('Add paragraphs and formatting', 'code-snippets'),
|
||||
shortcodes: __('Evaluate additional shortcode tags', 'code-snippets')
|
||||
}
|
||||
|
||||
const OPTION_DESCRIPTIONS: Record<keyof ShortcodeOptions, string> = {
|
||||
php: __('Run code within <?php ?> tags.', 'code-snippets'),
|
||||
format: __('Wrap output in paragraphs and apply formatting.', 'code-snippets'),
|
||||
shortcodes: __('Replace [shortcodes] embedded within the snippet.', 'code-snippets')
|
||||
}
|
||||
|
||||
const ModalContent = () => {
|
||||
const { snippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
const [options, setOptions] = useState<ShortcodeOptions>(() => ({
|
||||
php: snippet.code.includes('<?'),
|
||||
format: true,
|
||||
shortcodes: false
|
||||
}))
|
||||
|
||||
const shortcodeAtts: ShortcodeAtts = {
|
||||
id: snippet.id,
|
||||
network: snippet.network,
|
||||
...options,
|
||||
name: snippet.name
|
||||
}
|
||||
|
||||
const shortcodeTag = buildShortcodeTag(SHORTCODE_TAG, shortcodeAtts)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ShortcodeDescription />
|
||||
|
||||
<p className="shortcode-tag-wrapper">
|
||||
<code className="shortcode-tag">{shortcodeTag}</code>
|
||||
<CopyToClipboardButton primary text={shortcodeTag} />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<h4>{__('Shortcode Options', 'code-snippets')}</h4>
|
||||
|
||||
<CheckboxList
|
||||
options={['php', 'format', 'shortcodes']}
|
||||
checked={options}
|
||||
disabled={isReadOnly}
|
||||
setChecked={setOptions}
|
||||
optionLabels={OPTION_LABELS}
|
||||
optionDescriptions={OPTION_DESCRIPTIONS}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const ShortcodeInfo: React.FC = () => {
|
||||
const { snippet, isReadOnly } = useSnippetForm()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
return 'content' === snippet.scope && snippet.id
|
||||
? <div className="inline-form-field">
|
||||
<h4>{__('Shortcode', 'code-snippets')}</h4>
|
||||
<Button onClick={() => setIsModalOpen(true)} disabled={isReadOnly}>
|
||||
{__('See options', 'code-snippets')}
|
||||
</Button>
|
||||
|
||||
{isModalOpen
|
||||
? <Modal
|
||||
size="medium"
|
||||
className="code-snippets-modal"
|
||||
title={__('Embed Snippet with Shortcode', 'code-snippets')}
|
||||
onRequestClose={() => setIsModalOpen(false)}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<ModalContent />
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<Button link large onClick={() => setIsModalOpen(false)}>
|
||||
{__('Close Popup', 'code-snippets')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
: null}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { SubmitSnippetAction } from '../../../hooks/useSubmitSnippet'
|
||||
import { isCondition } from '../../../utils/snippets/snippets'
|
||||
import { isNetworkAdmin } from '../../../utils/screen'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { SubmitButton } from '../../common/SubmitButton'
|
||||
import type { SubmitButtonProps } from '../../common/SubmitButton'
|
||||
|
||||
const SaveButton = (props: SubmitButtonProps) => {
|
||||
const { snippet } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<SubmitButton
|
||||
large
|
||||
name={SubmitSnippetAction.SAVE}
|
||||
text={isCondition(snippet)
|
||||
? __('Save Condition', 'code-snippets')
|
||||
: __('Save Snippet', 'code-snippets')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ActivateOrDeactivateButtonProps {
|
||||
primaryActivate: boolean
|
||||
}
|
||||
|
||||
const ActivateOrDeactivateButton: React.FC<ActivateOrDeactivateButtonProps> = ({ primaryActivate }) => {
|
||||
const { snippet, isWorking } = useSnippetForm()
|
||||
|
||||
switch (true) {
|
||||
case isCondition(snippet) || snippet.shared_network && isNetworkAdmin():
|
||||
return null
|
||||
|
||||
case 'single-use' === snippet.scope:
|
||||
return (
|
||||
<SubmitButton
|
||||
large
|
||||
name={SubmitSnippetAction.SAVE_AND_EXECUTE}
|
||||
disabled={isWorking}
|
||||
text={__('Save and Execute Once', 'code-snippets')}
|
||||
/>
|
||||
)
|
||||
|
||||
case snippet.active:
|
||||
return (
|
||||
<SubmitButton
|
||||
name={SubmitSnippetAction.SAVE_AND_DEACTIVATE}
|
||||
disabled={isWorking}
|
||||
large
|
||||
text={__('Save and Deactivate', 'code-snippets')}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
case !snippet.active:
|
||||
return (
|
||||
<SubmitButton
|
||||
name={SubmitSnippetAction.SAVE_AND_ACTIVATE}
|
||||
primary={primaryActivate}
|
||||
disabled={isWorking}
|
||||
large
|
||||
text={__('Save and Activate', 'code-snippets')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const SubmitButtons: React.FC = () => {
|
||||
const { snippet } = useSnippetForm()
|
||||
|
||||
const activateByDefault =
|
||||
!!window.CODE_SNIPPETS_EDIT?.activateByDefault &&
|
||||
!snippet.active && 'single-use' !== snippet.scope &&
|
||||
(!snippet.shared_network || !isNetworkAdmin())
|
||||
|
||||
return <>
|
||||
{activateByDefault && <SaveButton primary={!activateByDefault} />}
|
||||
<ActivateOrDeactivateButton primaryActivate={activateByDefault} />
|
||||
{!activateByDefault && <SaveButton primary />}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet'
|
||||
import { handleUnknownError } from '../../../utils/errors'
|
||||
|
||||
export const ActivationSwitch = () => {
|
||||
const { snippet, isWorking } = useSnippetForm()
|
||||
const { submitSnippet } = useSubmitSnippet()
|
||||
|
||||
return (
|
||||
<div className="inline-form-field activation-switch-container">
|
||||
<h4>{__('Status')}</h4>
|
||||
|
||||
<label>
|
||||
{snippet.active
|
||||
? __('Active', 'code-snippets')
|
||||
: __('Inactive', 'code-snippets')}
|
||||
|
||||
<input
|
||||
id="activation-switch"
|
||||
type="checkbox"
|
||||
checked={snippet.active}
|
||||
disabled={isWorking || !!snippet.shared_network}
|
||||
className="switch"
|
||||
onChange={() => {
|
||||
submitSnippet(snippet.active
|
||||
? SubmitSnippetAction.SAVE_AND_DEACTIVATE
|
||||
: SubmitSnippetAction.SAVE_AND_ACTIVATE)
|
||||
.then(() => undefined)
|
||||
.catch(handleUnknownError)
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { Tooltip } from '../../common/Tooltip'
|
||||
|
||||
export const MultisiteSharingSettings: React.FC = () => {
|
||||
const { snippet, setSnippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div className="inline-form-field activation-switch-container">
|
||||
<h4>
|
||||
{__('Share with Subsites', 'code-snippets')}
|
||||
</h4>
|
||||
|
||||
<Tooltip inline start>
|
||||
{__('Instead of running on every site, allow this snippet to be activated on individual sites on the network.', 'code-snippets')}
|
||||
</Tooltip>
|
||||
|
||||
<label>
|
||||
{snippet.shared_network
|
||||
? __('Enabled', 'code-snippets')
|
||||
: __('Disabled', 'code-snippets')}
|
||||
|
||||
<input
|
||||
id="snippet_sharing"
|
||||
name="snippet_sharing"
|
||||
type="checkbox"
|
||||
className="switch"
|
||||
checked={!!snippet.shared_network}
|
||||
disabled={isReadOnly}
|
||||
onChange={event =>
|
||||
setSnippet(previous => ({
|
||||
...previous,
|
||||
active: false,
|
||||
shared_network: event.target.checked
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { Tooltip } from '../../common/Tooltip'
|
||||
|
||||
export const PriorityInput = () => {
|
||||
const { snippet, isReadOnly, setSnippet } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div className="snippet-priority inline-form-field">
|
||||
<h4>
|
||||
<label htmlFor="snippet-priority">
|
||||
{__('Priority', 'code-snippets')}
|
||||
</label>
|
||||
</h4>
|
||||
|
||||
<Tooltip block end>
|
||||
{__('Snippets with a lower priority number will run before those with a higher number.', 'code-snippets')}
|
||||
</Tooltip>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
id="snippet-priority"
|
||||
name="snippet_priority"
|
||||
value={snippet.priority}
|
||||
disabled={isReadOnly}
|
||||
onChange={event => setSnippet(previous => ({
|
||||
...previous,
|
||||
priority: parseInt(event.target.value, 10)
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
|
||||
export const RTLControl: React.FC = () => {
|
||||
const { codeEditorInstance } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div className="inline-form-field">
|
||||
<h4>
|
||||
<label htmlFor="snippet-code-direction">
|
||||
{__('Code Direction', 'code-snippets')}
|
||||
</label>
|
||||
</h4>
|
||||
|
||||
<select id="snippet-code-direction" onChange={event =>
|
||||
codeEditorInstance?.codemirror.setOption('direction', 'rtl' === event.target.value ? 'rtl' : 'ltr')
|
||||
}>
|
||||
<option value="ltr">{__('LTR', 'code-snippets')}</option>
|
||||
<option value="rtl">{__('RTL', 'code-snippets')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './EditorSidebar'
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { Button } from '../../common/Button'
|
||||
import {
|
||||
DuplicateActionSelector,
|
||||
DragDropUploadArea,
|
||||
SelectedFilesList,
|
||||
SnippetSelectionTable,
|
||||
ImportResultDisplay
|
||||
} from './components'
|
||||
import { ImportCard } from '../shared'
|
||||
import {
|
||||
useFileSelection,
|
||||
useSnippetSelection,
|
||||
useImportWorkflow
|
||||
} from './hooks'
|
||||
|
||||
type DuplicateAction = 'ignore' | 'replace' | 'skip'
|
||||
type Step = 'upload' | 'select'
|
||||
|
||||
export const FileUploadForm: React.FC = () => {
|
||||
const [duplicateAction, setDuplicateAction] = useState<DuplicateAction>('ignore')
|
||||
const [currentStep, setCurrentStep] = useState<Step>('upload')
|
||||
const selectSectionRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const fileSelection = useFileSelection()
|
||||
const importWorkflow = useImportWorkflow()
|
||||
const snippetSelection = useSnippetSelection(importWorkflow.availableSnippets)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 'select' && selectSectionRef.current) {
|
||||
selectSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
})
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
fileSelection.handleFileSelect(files)
|
||||
importWorkflow.clearUploadResult()
|
||||
}
|
||||
|
||||
const handleParseFiles = async () => {
|
||||
if (!fileSelection.selectedFiles) return
|
||||
|
||||
const success = await importWorkflow.parseFiles(fileSelection.selectedFiles)
|
||||
if (success) {
|
||||
snippetSelection.clearSelection()
|
||||
setCurrentStep('select')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportSelected = async () => {
|
||||
const snippetsToImport = snippetSelection.getSelectedSnippets()
|
||||
await importWorkflow.importSnippets(snippetsToImport, duplicateAction)
|
||||
}
|
||||
|
||||
const handleBackToUpload = () => {
|
||||
setCurrentStep('upload')
|
||||
fileSelection.clearFiles()
|
||||
snippetSelection.clearSelection()
|
||||
importWorkflow.resetWorkflow()
|
||||
}
|
||||
|
||||
const isUploadDisabled = !fileSelection.selectedFiles ||
|
||||
fileSelection.selectedFiles.length === 0 ||
|
||||
importWorkflow.isUploading
|
||||
|
||||
const isImportDisabled = snippetSelection.selectedSnippets.size === 0 ||
|
||||
importWorkflow.isImporting
|
||||
|
||||
return (
|
||||
<div className="wrap">
|
||||
<div className="import-form-container" style={{ maxWidth: '800px' }}>
|
||||
<p>{__('Upload one or more Code Snippets export files and the snippets will be imported.', 'code-snippets')}</p>
|
||||
|
||||
<p>
|
||||
{__('Afterward, you will need to visit the ', 'code-snippets')}
|
||||
<a href="admin.php?page=snippets">
|
||||
{__('All Snippets', 'code-snippets')}
|
||||
</a>
|
||||
{__(' page to activate the imported snippets.', 'code-snippets')}
|
||||
</p>
|
||||
|
||||
{currentStep === 'upload' && (
|
||||
<>
|
||||
|
||||
{(!importWorkflow.uploadResult || !importWorkflow.uploadResult.success) && (
|
||||
<>
|
||||
<DuplicateActionSelector
|
||||
value={duplicateAction}
|
||||
onChange={setDuplicateAction}
|
||||
/>
|
||||
|
||||
<ImportCard>
|
||||
<h2 style={{ margin: '0 0 1em 0' }}>{__('Choose Files', 'code-snippets')}</h2>
|
||||
<p className="description" style={{ marginBottom: '1em' }}>
|
||||
{__('Choose one or more Code Snippets (.xml or .json) files to parse and preview.', 'code-snippets')}
|
||||
</p>
|
||||
|
||||
<DragDropUploadArea
|
||||
fileInputRef={fileSelection.fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={importWorkflow.isUploading}
|
||||
/>
|
||||
|
||||
{fileSelection.selectedFiles && fileSelection.selectedFiles.length > 0 && (
|
||||
<SelectedFilesList
|
||||
files={fileSelection.selectedFiles}
|
||||
onRemoveFile={fileSelection.removeFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
primary
|
||||
onClick={handleParseFiles}
|
||||
disabled={isUploadDisabled}
|
||||
style={{ minWidth: '200px' }}
|
||||
>
|
||||
{importWorkflow.isUploading
|
||||
? __('Uploading files...', 'code-snippets')
|
||||
: __('Upload files', 'code-snippets')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCard>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentStep === 'select' && importWorkflow.availableSnippets.length > 0 && !importWorkflow.uploadResult?.success && (
|
||||
<ImportCard ref={selectSectionRef}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '20px' }}>
|
||||
<Button onClick={handleBackToUpload} className="button-link">
|
||||
{__('← Upload Different Files', 'code-snippets')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: '0' }}>{__('Available Snippets', 'code-snippets')} ({importWorkflow.availableSnippets.length})</h3>
|
||||
<p style={{ margin: '0.5em 0 1em 0', color: '#666' }}>
|
||||
{__('Select the snippets you want to import:', 'code-snippets')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={snippetSelection.handleSelectAll} style={{ marginRight: '10px' }}>
|
||||
{snippetSelection.isAllSelected
|
||||
? __('Deselect All', 'code-snippets')
|
||||
: __('Select All', 'code-snippets')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
onClick={handleImportSelected}
|
||||
disabled={isImportDisabled}
|
||||
>
|
||||
{importWorkflow.isImporting
|
||||
? __('Importing...', 'code-snippets')
|
||||
: __('Import Selected', 'code-snippets')} ({snippetSelection.selectedSnippets.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SnippetSelectionTable
|
||||
snippets={importWorkflow.availableSnippets}
|
||||
selectedSnippets={snippetSelection.selectedSnippets}
|
||||
isAllSelected={snippetSelection.isAllSelected}
|
||||
onSnippetToggle={snippetSelection.handleSnippetToggle}
|
||||
onSelectAll={snippetSelection.handleSelectAll}
|
||||
/>
|
||||
|
||||
<div style={{ textAlign: 'end', marginTop: '1em' }}>
|
||||
<Button onClick={snippetSelection.handleSelectAll} style={{ marginRight: '10px' }}>
|
||||
{snippetSelection.isAllSelected
|
||||
? __('Deselect All', 'code-snippets')
|
||||
: __('Select All', 'code-snippets')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
onClick={handleImportSelected}
|
||||
disabled={isImportDisabled}
|
||||
>
|
||||
{importWorkflow.isImporting
|
||||
? __('Importing...', 'code-snippets')
|
||||
: __('Import Selected', 'code-snippets')} ({snippetSelection.selectedSnippets.size})
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCard>
|
||||
)}
|
||||
|
||||
{importWorkflow.uploadResult && (
|
||||
<ImportResultDisplay result={importWorkflow.uploadResult} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useDragAndDrop } from '../hooks/useDragAndDrop'
|
||||
|
||||
interface DragDropUploadAreaProps {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onFileSelect: (files: FileList | null) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export const DragDropUploadArea: React.FC<DragDropUploadAreaProps> = ({
|
||||
fileInputRef,
|
||||
onFileSelect,
|
||||
disabled = false
|
||||
}) => {
|
||||
const { dragOver, handleDragOver, handleDragLeave, handleDrop } = useDragAndDrop({
|
||||
onFilesDrop: onFileSelect
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (!disabled) {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`upload-drop-zone ${dragOver ? 'drag-over' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? '#0073aa' : '#ccd0d4'}`,
|
||||
borderRadius: '4px',
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: dragOver ? '#f0f6fc' : disabled ? '#f6f7f7' : '#fafafa',
|
||||
marginBottom: '20px',
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: disabled ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px', color: '#666' }}>📁</div>
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '500' }}>
|
||||
{__('Drag and drop files here, or click to browse', 'code-snippets')}
|
||||
</p>
|
||||
<p style={{ margin: '0', color: '#666', fontSize: '14px' }}>
|
||||
{__('Supports JSON and XML files', 'code-snippets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json,text/xml"
|
||||
multiple
|
||||
onChange={(e) => onFileSelect(e.target.files)}
|
||||
style={{ display: 'none' }}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
type DuplicateAction = 'ignore' | 'replace' | 'skip'
|
||||
|
||||
interface DuplicateActionSelectorProps {
|
||||
value: DuplicateAction
|
||||
onChange: (action: DuplicateAction) => void
|
||||
}
|
||||
|
||||
export const DuplicateActionSelector: React.FC<DuplicateActionSelectorProps> = ({
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
return (
|
||||
<ImportCard>
|
||||
<h2 style={{ margin: '0 0 1em 0' }}>{__('Duplicate Snippets', 'code-snippets')}</h2>
|
||||
<p className="description" style={{ marginBottom: '1em' }}>
|
||||
{__('What should happen if an existing snippet is found with an identical name to an imported snippet?', 'code-snippets')}
|
||||
</p>
|
||||
|
||||
<fieldset>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="duplicate_action"
|
||||
value="ignore"
|
||||
checked={value === 'ignore'}
|
||||
onChange={(e) => onChange(e.target.value as DuplicateAction)}
|
||||
style={{ marginTop: '2px' }}
|
||||
/>
|
||||
<span>
|
||||
{__('Ignore any duplicate snippets: import all snippets from the file regardless and leave all existing snippets unchanged.', 'code-snippets')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="duplicate_action"
|
||||
value="replace"
|
||||
checked={value === 'replace'}
|
||||
onChange={(e) => onChange(e.target.value as DuplicateAction)}
|
||||
style={{ marginTop: '2px' }}
|
||||
/>
|
||||
<span>
|
||||
{__('Replace any existing snippets with a newly imported snippet of the same name.', 'code-snippets')}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="duplicate_action"
|
||||
value="skip"
|
||||
checked={value === 'skip'}
|
||||
onChange={(e) => onChange(e.target.value as DuplicateAction)}
|
||||
style={{ marginTop: '2px' }}
|
||||
/>
|
||||
<span>
|
||||
{__('Do not import any duplicate snippets; leave all existing snippets unchanged.', 'code-snippets')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
interface ImportResult {
|
||||
success: boolean
|
||||
message: string
|
||||
imported?: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
interface ImportResultDisplayProps {
|
||||
result: ImportResult
|
||||
}
|
||||
|
||||
export const ImportResultDisplay: React.FC<ImportResultDisplayProps> = ({ result }) => {
|
||||
return (
|
||||
<ImportCard>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
|
||||
<div style={{
|
||||
backgroundColor: result.success ? '#00a32a' : '#d63638',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span style={{ color: 'white', fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{result.success ? '✓' : '✕'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
{result.success
|
||||
? __('Import Successful!', 'code-snippets')
|
||||
: __('Import Failed', 'code-snippets')
|
||||
}
|
||||
</h3>
|
||||
<p style={{ margin: '0 0 8px 0', color: '#666' }}>
|
||||
{result.message}
|
||||
</p>
|
||||
|
||||
{result.success && (
|
||||
<p style={{ margin: '0', color: '#666' }}>
|
||||
{__('Go to ', 'code-snippets')}
|
||||
<a href="admin.php?page=snippets" style={{ color: '#2271b1', textDecoration: 'none' }}>
|
||||
{__('All Snippets', 'code-snippets')}
|
||||
</a>
|
||||
{__(' to activate your imported snippets.', 'code-snippets')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<h4 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#d63638' }}>
|
||||
{__('Warnings:', 'code-snippets')}
|
||||
</h4>
|
||||
<ul style={{ margin: '0', paddingLeft: '20px' }}>
|
||||
{result.warnings.map((warning, index) => (
|
||||
<li key={index} style={{ color: '#666', fontSize: '14px' }}>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { formatFileSize } from '../utils/fileUtils'
|
||||
|
||||
interface SelectedFilesListProps {
|
||||
files: FileList
|
||||
onRemoveFile: (index: number) => void
|
||||
}
|
||||
|
||||
export const SelectedFilesList: React.FC<SelectedFilesListProps> = ({
|
||||
files,
|
||||
onRemoveFile
|
||||
}) => {
|
||||
return (
|
||||
<div className="selected-files" style={{ marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: '0 0 12px 0', fontSize: '14px', fontWeight: '600' }}>
|
||||
{__('Selected Files:', 'code-snippets')} ({files.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{Array.from(files).map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ddd'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '16px' }}>📄</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: '500' }}>{file.name}</div>
|
||||
<div style={{ fontSize: '12px', color: '#666' }}>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemoveFile(index)
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#d63638',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
padding: '4px'
|
||||
}}
|
||||
title={__('Remove file', 'code-snippets')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import type { ImportableSnippet } from '../../../../hooks/useFileUploadAPI'
|
||||
|
||||
interface SnippetSelectionTableProps {
|
||||
snippets: ImportableSnippet[]
|
||||
selectedSnippets: Set<number | string>
|
||||
isAllSelected: boolean
|
||||
onSnippetToggle: (snippetId: number | string) => void
|
||||
onSelectAll: () => void
|
||||
}
|
||||
|
||||
export const SnippetSelectionTable: React.FC<SnippetSelectionTableProps> = ({
|
||||
snippets,
|
||||
selectedSnippets,
|
||||
isAllSelected,
|
||||
onSnippetToggle,
|
||||
onSelectAll
|
||||
}) => {
|
||||
const getTypeColor = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'css': return '#9B59B6'
|
||||
case 'js': return '#FFEB3B'
|
||||
case 'html': return '#EF6A36'
|
||||
default: return '#1D97C6'
|
||||
}
|
||||
}
|
||||
|
||||
const truncateDescription = (description: string | undefined): string => {
|
||||
const desc = description || __('No description', 'code-snippets')
|
||||
return desc.length > 50 ? desc.substring(0, 50) + '...' : desc
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="wp-list-table widefat fixed striped" style={{ borderRadius: '5px', tableLayout: 'fixed' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="check-column" style={{ padding: '8px 0', width: '40px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th scope="col" style={{ width: '200px' }}>{__('Name', 'code-snippets')}</th>
|
||||
<th scope="col" style={{ width: '90px', textAlign: 'center' }}>{__('Type', 'code-snippets')}</th>
|
||||
<th scope="col" style={{ width: 'auto' }}>{__('Description', 'code-snippets')}</th>
|
||||
<th scope="col" style={{ width: '120px' }}>{__('Tags', 'code-snippets')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snippets.map(snippet => (
|
||||
<tr key={snippet.table_data.id}>
|
||||
<th scope="row" className="check-column">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSnippets.has(snippet.table_data.id)}
|
||||
onChange={() => onSnippetToggle(snippet.table_data.id)}
|
||||
/>
|
||||
</th>
|
||||
<td>
|
||||
<strong>{snippet.table_data.title}</strong>
|
||||
{snippet.source_file && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||||
from {snippet.source_file}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: '90px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
backgroundColor: getTypeColor(snippet.table_data.type),
|
||||
color: 'white',
|
||||
padding: '3px 6px',
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
borderRadius: '3px'
|
||||
}}>
|
||||
{snippet.table_data.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{truncateDescription(snippet.table_data.description)}
|
||||
</td>
|
||||
<td>{snippet.table_data.tags || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DuplicateActionSelector } from './DuplicateActionSelector'
|
||||
export { DragDropUploadArea } from './DragDropUploadArea'
|
||||
export { SelectedFilesList } from './SelectedFilesList'
|
||||
export { SnippetSelectionTable } from './SnippetSelectionTable'
|
||||
export { ImportResultDisplay } from './ImportResultDisplay'
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useDragAndDrop } from './useDragAndDrop'
|
||||
export { useFileSelection } from './useFileSelection'
|
||||
export { useSnippetSelection } from './useSnippetSelection'
|
||||
export { useImportWorkflow } from './useImportWorkflow'
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface UseDragAndDropProps {
|
||||
onFilesDrop: (files: FileList) => void
|
||||
}
|
||||
|
||||
export const useDragAndDrop = ({ onFilesDrop }: UseDragAndDropProps) => {
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
onFilesDrop(files)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
dragOver,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { removeFileFromList } from '../utils/fileUtils'
|
||||
|
||||
export const useFileSelection = () => {
|
||||
const [selectedFiles, setSelectedFiles] = useState<FileList | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFileSelect = (files: FileList | null) => {
|
||||
setSelectedFiles(files)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
if (!selectedFiles) return
|
||||
|
||||
const newFiles = removeFileFromList(selectedFiles, index)
|
||||
setSelectedFiles(newFiles)
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.files = newFiles
|
||||
}
|
||||
}
|
||||
|
||||
const clearFiles = () => {
|
||||
setSelectedFiles(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return {
|
||||
selectedFiles,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
removeFile,
|
||||
clearFiles,
|
||||
triggerFileInput
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useFileUploadAPI, type ImportableSnippet } from '../../../../hooks/useFileUploadAPI'
|
||||
import { isNetworkAdmin } from '../../../../utils/screen'
|
||||
|
||||
type DuplicateAction = 'ignore' | 'replace' | 'skip'
|
||||
|
||||
interface UploadResult {
|
||||
success: boolean
|
||||
message: string
|
||||
imported?: number
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export const useImportWorkflow = () => {
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [availableSnippets, setAvailableSnippets] = useState<ImportableSnippet[]>([])
|
||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
||||
|
||||
const fileUploadAPI = useFileUploadAPI()
|
||||
|
||||
const parseFiles = async (files: FileList): Promise<boolean> => {
|
||||
if (!files || files.length === 0) {
|
||||
alert(__('Please select files to upload.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
|
||||
setIsUploading(true)
|
||||
setUploadResult(null)
|
||||
|
||||
try {
|
||||
const response = await fileUploadAPI.parseFiles({ files })
|
||||
|
||||
setAvailableSnippets(response.data.snippets)
|
||||
|
||||
if (response.data.warnings && response.data.warnings.length > 0) {
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
warnings: response.data.warnings
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
} catch (error) {
|
||||
console.error('Parse error:', error)
|
||||
setUploadResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : __('An unknown error occurred.', 'code-snippets')
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const importSnippets = async (
|
||||
snippetsToImport: ImportableSnippet[],
|
||||
duplicateAction: DuplicateAction
|
||||
): Promise<boolean> => {
|
||||
if (snippetsToImport.length === 0) {
|
||||
alert(__('Please select snippets to import.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
setUploadResult(null)
|
||||
|
||||
try {
|
||||
const response = await fileUploadAPI.importSnippets({
|
||||
snippets: snippetsToImport,
|
||||
duplicate_action: duplicateAction,
|
||||
network: isNetworkAdmin()
|
||||
})
|
||||
|
||||
setUploadResult({
|
||||
success: true,
|
||||
message: response.data.message,
|
||||
imported: response.data.imported
|
||||
})
|
||||
|
||||
return true
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
setUploadResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : __('An unknown error occurred.', 'code-snippets')
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetWorkflow = () => {
|
||||
setAvailableSnippets([])
|
||||
setUploadResult(null)
|
||||
}
|
||||
|
||||
const clearUploadResult = () => {
|
||||
setUploadResult(null)
|
||||
}
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
isImporting,
|
||||
availableSnippets,
|
||||
uploadResult,
|
||||
parseFiles,
|
||||
importSnippets,
|
||||
resetWorkflow,
|
||||
clearUploadResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import type { ImportableSnippet } from '../../../../hooks/useFileUploadAPI'
|
||||
|
||||
export const useSnippetSelection = (availableSnippets: ImportableSnippet[]) => {
|
||||
const [selectedSnippets, setSelectedSnippets] = useState<Set<number | string>>(new Set())
|
||||
|
||||
const handleSnippetToggle = (snippetId: number | string) => {
|
||||
const newSelected = new Set(selectedSnippets)
|
||||
if (newSelected.has(snippetId)) {
|
||||
newSelected.delete(snippetId)
|
||||
} else {
|
||||
newSelected.add(snippetId)
|
||||
}
|
||||
setSelectedSnippets(newSelected)
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedSnippets.size === availableSnippets.length) {
|
||||
setSelectedSnippets(new Set())
|
||||
} else {
|
||||
setSelectedSnippets(new Set(availableSnippets.map(snippet => snippet.table_data.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedSnippets(new Set())
|
||||
}
|
||||
|
||||
const getSelectedSnippets = () => {
|
||||
return availableSnippets.filter(snippet =>
|
||||
selectedSnippets.has(snippet.table_data.id)
|
||||
)
|
||||
}
|
||||
|
||||
const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0
|
||||
|
||||
return {
|
||||
selectedSnippets,
|
||||
handleSnippetToggle,
|
||||
handleSelectAll,
|
||||
clearSelection,
|
||||
getSelectedSnippets,
|
||||
isAllSelected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export const removeFileFromList = (fileList: FileList, indexToRemove: number): FileList => {
|
||||
const dt = new DataTransfer()
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
if (i !== indexToRemove) {
|
||||
dt.items.add(fileList[i])
|
||||
}
|
||||
}
|
||||
return dt.files
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import {
|
||||
ImporterSelector,
|
||||
ImportOptions,
|
||||
SimpleSnippetTable,
|
||||
StatusDisplay
|
||||
} from './components'
|
||||
import { ImportCard } from '../shared'
|
||||
import {
|
||||
useImporterSelection,
|
||||
useSnippetImport,
|
||||
useImportSnippetSelection
|
||||
} from './hooks'
|
||||
|
||||
export const ImportForm: React.FC = () => {
|
||||
const [autoAddTags, setAutoAddTags] = useState<boolean>(false)
|
||||
|
||||
const importerSelection = useImporterSelection()
|
||||
const snippetImport = useSnippetImport()
|
||||
const snippetSelection = useImportSnippetSelection(snippetImport.snippets)
|
||||
|
||||
const handleImporterChange = async (newImporter: string) => {
|
||||
importerSelection.handleImporterChange(newImporter)
|
||||
snippetSelection.clearSelection()
|
||||
snippetImport.resetAll()
|
||||
|
||||
if (newImporter) {
|
||||
await snippetImport.loadSnippets(newImporter)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
const selectedIds = Array.from(snippetSelection.selectedSnippets)
|
||||
const success = await snippetImport.importSnippets(
|
||||
importerSelection.selectedImporter,
|
||||
selectedIds,
|
||||
autoAddTags,
|
||||
importerSelection.tagValue
|
||||
)
|
||||
|
||||
if (success) {
|
||||
snippetSelection.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
if (importerSelection.isLoading) {
|
||||
return (
|
||||
<div className="wrap">
|
||||
<p>{__('Loading importers...', 'code-snippets')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (importerSelection.error) {
|
||||
return (
|
||||
<div className="wrap">
|
||||
<div className="notice notice-error">
|
||||
<p>{__('Error loading importers:', 'code-snippets')} {importerSelection.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wrap">
|
||||
<div className="import-form-container" style={{ maxWidth: '800px' }}>
|
||||
<p>{__('If you are using another Snippets plugin, you can import all existing snippets to your Code Snippets library.', 'code-snippets')}</p>
|
||||
|
||||
<ImporterSelector
|
||||
importers={importerSelection.importers}
|
||||
selectedImporter={importerSelection.selectedImporter}
|
||||
onImporterChange={handleImporterChange}
|
||||
isLoading={snippetImport.isLoadingSnippets}
|
||||
/>
|
||||
|
||||
{snippetImport.snippetsError && (
|
||||
<StatusDisplay
|
||||
type="error"
|
||||
title={__('Error loading snippets', 'code-snippets')}
|
||||
message={snippetImport.snippetsError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snippetImport.importError && (
|
||||
<StatusDisplay
|
||||
type="error"
|
||||
title={__('Error importing snippets', 'code-snippets')}
|
||||
message={snippetImport.importError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{snippetImport.importSuccess.length > 0 && (
|
||||
<StatusDisplay
|
||||
type="success"
|
||||
title={`${snippetImport.importSuccess.length} ${__('Snippets imported!', 'code-snippets')}`}
|
||||
message={__('We successfully imported all snippets to your library. Go to ', 'code-snippets')}
|
||||
showSnippetsLink
|
||||
/>
|
||||
)}
|
||||
|
||||
{importerSelection.selectedImporter &&
|
||||
!snippetImport.isLoadingSnippets &&
|
||||
!snippetImport.snippetsError &&
|
||||
snippetImport.snippets.length === 0 &&
|
||||
snippetImport.importSuccess.length === 0 && (
|
||||
<ImportCard>
|
||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: '#666' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📭</div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '18px', color: '#333' }}>
|
||||
{__('No snippets found', 'code-snippets')}
|
||||
</h3>
|
||||
<p style={{ margin: '0', fontSize: '14px' }}>
|
||||
{__('No snippets were found for the selected plugin. Make sure the plugin is installed and has snippets configured.', 'code-snippets')}
|
||||
</p>
|
||||
</div>
|
||||
</ImportCard>
|
||||
)}
|
||||
|
||||
{snippetImport.snippets.length > 0 && (
|
||||
<>
|
||||
<ImportOptions
|
||||
autoAddTags={autoAddTags}
|
||||
tagValue={importerSelection.tagValue}
|
||||
onAutoAddTagsChange={setAutoAddTags}
|
||||
onTagValueChange={importerSelection.setTagValue}
|
||||
/>
|
||||
|
||||
<SimpleSnippetTable
|
||||
snippets={snippetImport.snippets}
|
||||
selectedSnippets={snippetSelection.selectedSnippets}
|
||||
onSnippetToggle={snippetSelection.handleSnippetToggle}
|
||||
onSelectAll={snippetSelection.handleSelectAll}
|
||||
onImport={handleImport}
|
||||
isImporting={snippetImport.isImporting}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
interface ImportOptionsProps {
|
||||
autoAddTags: boolean
|
||||
tagValue: string
|
||||
onAutoAddTagsChange: (enabled: boolean) => void
|
||||
onTagValueChange: (value: string) => void
|
||||
}
|
||||
|
||||
export const ImportOptions: React.FC<ImportOptionsProps> = ({
|
||||
autoAddTags,
|
||||
tagValue,
|
||||
onAutoAddTagsChange,
|
||||
onTagValueChange
|
||||
}) => {
|
||||
return (
|
||||
<ImportCard>
|
||||
<h2 style={{ margin: '0 0 1em 0' }}>{__('Import options', 'code-snippets')}</h2>
|
||||
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoAddTags}
|
||||
onChange={(e) => onAutoAddTagsChange(e.target.checked)}
|
||||
style={{ marginTop: '2px' }}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div>
|
||||
<strong>{__('Automatically add Tag', 'code-snippets')}</strong>
|
||||
<br />
|
||||
<span style={{ color: '#666', fontSize: '0.9em' }}>
|
||||
{__('For your convenience, we can add a tag on every imported snippet.', 'code-snippets')}
|
||||
</span>
|
||||
</div>
|
||||
{autoAddTags && (
|
||||
<div style={{ marginTop: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={tagValue}
|
||||
onChange={(e) => onTagValueChange(e.target.value)}
|
||||
placeholder={__('Add tag...', 'code-snippets')}
|
||||
className="regular-text"
|
||||
style={{ width: '100%', maxWidth: '300px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import type { Importer } from '../../../../hooks/useImportersAPI'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
interface ImporterSelectorProps {
|
||||
importers: Importer[]
|
||||
selectedImporter: string
|
||||
onImporterChange: (importerName: string) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const ImporterSelector: React.FC<ImporterSelectorProps> = ({
|
||||
importers,
|
||||
selectedImporter,
|
||||
onImporterChange,
|
||||
isLoading
|
||||
}) => {
|
||||
return (
|
||||
<ImportCard variant="controls">
|
||||
<label htmlFor="importer-select">
|
||||
<h2 style={{ margin: '0 0 1em 0' }}>{__('Select Plugin', 'code-snippets')}</h2>
|
||||
</label>
|
||||
<select
|
||||
id="importer-select"
|
||||
value={selectedImporter}
|
||||
onChange={(event) => onImporterChange(event.target.value)}
|
||||
className="regular-text"
|
||||
style={{ display: 'block', marginTop: '5px', width: '100%', maxWidth: '300px' }}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<option value="">{__('-- Select an importer --', 'code-snippets')}</option>
|
||||
{importers.map(importer => (
|
||||
<option
|
||||
key={importer.name}
|
||||
value={importer.name}
|
||||
disabled={!importer.is_active}
|
||||
>
|
||||
{importer.title} {!importer.is_active ? __('(Inactive)', 'code-snippets') : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLoading && (
|
||||
<p style={{ margin: '10px 0 0 0', color: '#666', fontSize: '14px' }}>
|
||||
{__('Loading snippets...', 'code-snippets')}
|
||||
</p>
|
||||
)}
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { Button } from '../../../common/Button'
|
||||
import type { ImportableSnippet } from '../../../../hooks/useImportersAPI'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
interface SimpleSnippetTableProps {
|
||||
snippets: ImportableSnippet[]
|
||||
selectedSnippets: Set<number>
|
||||
onSnippetToggle: (snippetId: number) => void
|
||||
onSelectAll: () => void
|
||||
onImport: () => void
|
||||
isImporting: boolean
|
||||
}
|
||||
|
||||
export const SimpleSnippetTable: React.FC<SimpleSnippetTableProps> = ({
|
||||
snippets,
|
||||
selectedSnippets,
|
||||
onSnippetToggle,
|
||||
onSelectAll,
|
||||
onImport,
|
||||
isImporting
|
||||
}) => {
|
||||
const isAllSelected = selectedSnippets.size === snippets.length && snippets.length > 0
|
||||
|
||||
return (
|
||||
<ImportCard className="snippets-table-container">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: '0' }}>{__('Available Snippets', 'code-snippets')} ({snippets.length})</h2>
|
||||
<p style={{ margin: '0.5em 0 1em 0' }}>{__('We found the following snippets.', 'code-snippets')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button onClick={onSelectAll} style={{ marginRight: '10px' }}>
|
||||
{isAllSelected
|
||||
? __('Deselect All', 'code-snippets')
|
||||
: __('Select All', 'code-snippets')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
onClick={onImport}
|
||||
disabled={selectedSnippets.size === 0 || isImporting}
|
||||
>
|
||||
{isImporting
|
||||
? __('Importing...', 'code-snippets')
|
||||
: __('Import Selected', 'code-snippets')} ({selectedSnippets.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table className="wp-list-table widefat fixed striped" style={{ borderRadius: '5px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="check-column" style={{ padding: '8px 0' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={onSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th scope="col">{__('Snippet Name', 'code-snippets')}</th>
|
||||
<th scope="col" style={{ textAlign: 'end', width: '50px' }}>{__('ID', 'code-snippets')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snippets.map(snippet => (
|
||||
<tr key={snippet.table_data.id}>
|
||||
<th scope="row" className="check-column">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSnippets.has(snippet.table_data.id)}
|
||||
onChange={() => onSnippetToggle(snippet.table_data.id)}
|
||||
/>
|
||||
</th>
|
||||
<td>{snippet.table_data.title}</td>
|
||||
<td style={{ textAlign: 'end', width: '50px' }}>{snippet.table_data.id}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ textAlign: 'end', marginTop: '1em' }}>
|
||||
<Button onClick={onSelectAll} style={{ marginRight: '10px' }}>
|
||||
{isAllSelected
|
||||
? __('Deselect All', 'code-snippets')
|
||||
: __('Select All', 'code-snippets')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
primary
|
||||
onClick={onImport}
|
||||
disabled={selectedSnippets.size === 0 || isImporting}
|
||||
>
|
||||
{isImporting
|
||||
? __('Importing...', 'code-snippets')
|
||||
: __('Import Selected', 'code-snippets')} ({selectedSnippets.size})
|
||||
</Button>
|
||||
</div>
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { ImportCard } from '../../shared'
|
||||
|
||||
interface StatusDisplayProps {
|
||||
type: 'error' | 'success'
|
||||
title: string
|
||||
message: string
|
||||
showSnippetsLink?: boolean
|
||||
}
|
||||
|
||||
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
showSnippetsLink = false
|
||||
}) => {
|
||||
const isError = type === 'error'
|
||||
|
||||
return (
|
||||
<ImportCard variant="controls" style={{ display: 'flex', alignItems: 'flex-start', gap: '12px', marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
backgroundColor: isError ? '#d63638' : '#00a32a',
|
||||
borderRadius: '50%',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
<span style={{ color: 'white', fontSize: '14px', fontWeight: 'bold' }}>
|
||||
{isError ? '✕' : '✓'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: '600' }}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{ margin: '0', color: '#666' }}>
|
||||
{message}
|
||||
{showSnippetsLink && (
|
||||
<>
|
||||
{' '}
|
||||
<a href="admin.php?page=snippets" style={{ color: '#2271b1', textDecoration: 'none' }}>
|
||||
{__('Code Snippets Library', 'code-snippets')}
|
||||
</a>.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</ImportCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ImporterSelector } from './ImporterSelector'
|
||||
export { ImportOptions } from './ImportOptions'
|
||||
export { SimpleSnippetTable } from './SimpleSnippetTable'
|
||||
export { StatusDisplay } from './StatusDisplay'
|
||||
@@ -0,0 +1,3 @@
|
||||
export { useImporterSelection } from './useImporterSelection'
|
||||
export { useSnippetImport } from './useSnippetImport'
|
||||
export { useImportSnippetSelection } from './useImportSnippetSelection'
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import type { ImportableSnippet } from '../../../../hooks/useImportersAPI'
|
||||
|
||||
export const useImportSnippetSelection = (availableSnippets: ImportableSnippet[]) => {
|
||||
const [selectedSnippets, setSelectedSnippets] = useState<Set<number>>(new Set())
|
||||
|
||||
const handleSnippetToggle = (snippetId: number) => {
|
||||
const newSelected = new Set(selectedSnippets)
|
||||
if (newSelected.has(snippetId)) {
|
||||
newSelected.delete(snippetId)
|
||||
} else {
|
||||
newSelected.add(snippetId)
|
||||
}
|
||||
setSelectedSnippets(newSelected)
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedSnippets.size === availableSnippets.length) {
|
||||
setSelectedSnippets(new Set())
|
||||
} else {
|
||||
setSelectedSnippets(new Set(availableSnippets.map(snippet => snippet.table_data.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedSnippets(new Set())
|
||||
}
|
||||
|
||||
const getSelectedSnippets = () => {
|
||||
return availableSnippets.filter(snippet =>
|
||||
selectedSnippets.has(snippet.table_data.id)
|
||||
)
|
||||
}
|
||||
|
||||
const isAllSelected = selectedSnippets.size === availableSnippets.length && availableSnippets.length > 0
|
||||
|
||||
return {
|
||||
selectedSnippets,
|
||||
handleSnippetToggle,
|
||||
handleSelectAll,
|
||||
clearSelection,
|
||||
getSelectedSnippets,
|
||||
isAllSelected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useImportersAPI, type Importer } from '../../../../hooks/useImportersAPI'
|
||||
|
||||
export const useImporterSelection = () => {
|
||||
const [importers, setImporters] = useState<Importer[]>([])
|
||||
const [selectedImporter, setSelectedImporter] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [tagValue, setTagValue] = useState<string>('')
|
||||
|
||||
const importersAPI = useImportersAPI()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchImporters = async () => {
|
||||
try {
|
||||
const response = await importersAPI.fetchAll()
|
||||
setImporters(response.data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchImporters()
|
||||
}, [importersAPI])
|
||||
|
||||
const handleImporterChange = (newImporter: string) => {
|
||||
setSelectedImporter(newImporter)
|
||||
setTagValue(`imported-${newImporter}`)
|
||||
}
|
||||
|
||||
return {
|
||||
importers,
|
||||
selectedImporter,
|
||||
isLoading,
|
||||
error,
|
||||
tagValue,
|
||||
setTagValue,
|
||||
handleImporterChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useImportersAPI, type ImportableSnippet } from '../../../../hooks/useImportersAPI'
|
||||
import { isNetworkAdmin } from '../../../../utils/screen'
|
||||
|
||||
export const useSnippetImport = () => {
|
||||
const [snippets, setSnippets] = useState<ImportableSnippet[]>([])
|
||||
const [isLoadingSnippets, setIsLoadingSnippets] = useState(false)
|
||||
const [snippetsError, setSnippetsError] = useState<string | null>(null)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
const [importSuccess, setImportSuccess] = useState<number[]>([])
|
||||
|
||||
const importersAPI = useImportersAPI()
|
||||
|
||||
const loadSnippets = async (importerName: string): Promise<boolean> => {
|
||||
if (!importerName) {
|
||||
alert(__('Please select an importer.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
|
||||
setIsLoadingSnippets(true)
|
||||
setSnippetsError(null)
|
||||
setSnippets([])
|
||||
clearResults()
|
||||
|
||||
try {
|
||||
const response = await importersAPI.fetchSnippets(importerName)
|
||||
setSnippets(response.data)
|
||||
return true
|
||||
} catch (err) {
|
||||
setSnippetsError(err instanceof Error ? err.message : 'Unknown error')
|
||||
return false
|
||||
} finally {
|
||||
setIsLoadingSnippets(false)
|
||||
}
|
||||
}
|
||||
|
||||
const importSnippets = async (
|
||||
importerName: string,
|
||||
selectedSnippetIds: number[],
|
||||
autoAddTags: boolean,
|
||||
tagValue: string
|
||||
): Promise<boolean> => {
|
||||
if (selectedSnippetIds.length === 0) {
|
||||
alert(__('Please select snippets to import.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
|
||||
if (!importerName) {
|
||||
alert(__('Please select an importer.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
setImportError(null)
|
||||
setImportSuccess([])
|
||||
|
||||
try {
|
||||
const response = await importersAPI.importSnippets(importerName, {
|
||||
ids: selectedSnippetIds,
|
||||
network: isNetworkAdmin(),
|
||||
auto_add_tags: autoAddTags,
|
||||
tag_value: autoAddTags ? tagValue : undefined
|
||||
})
|
||||
|
||||
setImportSuccess(response.data.imported)
|
||||
|
||||
if (response.data.imported.length > 0) {
|
||||
setSnippets([])
|
||||
return true
|
||||
} else {
|
||||
alert(__('No snippets were imported.', 'code-snippets'))
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
setImportError(err instanceof Error ? err.message : 'Unknown error')
|
||||
return false
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearResults = () => {
|
||||
setImportSuccess([])
|
||||
setImportError(null)
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
setSnippets([])
|
||||
clearResults()
|
||||
setSnippetsError(null)
|
||||
}
|
||||
|
||||
return {
|
||||
snippets,
|
||||
isLoadingSnippets,
|
||||
snippetsError,
|
||||
isImporting,
|
||||
importError,
|
||||
importSuccess,
|
||||
loadSnippets,
|
||||
importSnippets,
|
||||
clearResults,
|
||||
resetAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ImportForm'
|
||||
62
plugins/code-snippets/js/components/Import/ImportApp.tsx
Normal file
62
plugins/code-snippets/js/components/Import/ImportApp.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { FileUploadForm } from './FromFileUpload/FileUploadForm'
|
||||
import { ImportForm } from './FromOtherPlugins/ImportForm'
|
||||
import { ImportSection } from './shared'
|
||||
|
||||
type TabType = 'upload' | 'plugins'
|
||||
|
||||
export const ImportApp: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('upload')
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const tabParam = urlParams.get('tab') as TabType
|
||||
if (tabParam === 'plugins' || tabParam === 'upload') {
|
||||
setActiveTab(tabParam)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTabChange = (tab: TabType) => {
|
||||
setActiveTab(tab)
|
||||
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('tab', tab)
|
||||
window.history.replaceState({}, '', url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="narrow" style={{ maxWidth: '800px' }}>
|
||||
<h2 className="nav-tab-wrapper" style={{ marginBottom: '20px' }}>
|
||||
<a
|
||||
className={`nav-tab${activeTab === 'upload' ? ' nav-tab-active' : ''}`}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleTabChange('upload')
|
||||
}}
|
||||
>
|
||||
{__('Import Snippets', 'code-snippets')}
|
||||
</a>
|
||||
<a
|
||||
className={`nav-tab${activeTab === 'plugins' ? ' nav-tab-active' : ''}`}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleTabChange('plugins')
|
||||
}}
|
||||
>
|
||||
{__('Import from other plugins', 'code-snippets')}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<ImportSection active={activeTab === 'upload'}>
|
||||
<FileUploadForm />
|
||||
</ImportSection>
|
||||
|
||||
<ImportSection active={activeTab === 'plugins'}>
|
||||
<ImportForm />
|
||||
</ImportSection>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
export interface ImportCardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'className'> {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
variant?: 'default' | 'controls'
|
||||
}
|
||||
|
||||
export const ImportCard = React.forwardRef<HTMLDivElement, ImportCardProps>(({
|
||||
children,
|
||||
className,
|
||||
variant = 'default',
|
||||
style,
|
||||
...props
|
||||
}, ref) => {
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '25px',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid #e0e0e0',
|
||||
marginBottom: '10px',
|
||||
width: '100%',
|
||||
...style
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classnames(
|
||||
{
|
||||
'import-controls': variant === 'controls'
|
||||
},
|
||||
className
|
||||
)}
|
||||
style={cardStyle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
ImportCard.displayName = 'ImportCard'
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
export interface ImportSectionProps extends Omit<HTMLAttributes<HTMLDivElement>, 'style'> {
|
||||
children: React.ReactNode
|
||||
active?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const ImportSection: React.FC<ImportSectionProps> = ({
|
||||
children,
|
||||
active = false,
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
display: active ? 'block' : 'none',
|
||||
paddingTop: 0,
|
||||
...style
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={sectionStyle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { ImportCard } from './ImportCard'
|
||||
export type { ImportCardProps } from './ImportCard'
|
||||
export { ImportSection } from './ImportSection'
|
||||
export type { ImportSectionProps } from './ImportSection'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './components'
|
||||
192
plugins/code-snippets/js/components/SnippetForm/SnippetForm.tsx
Normal file
192
plugins/code-snippets/js/components/SnippetForm/SnippetForm.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { addQueryArgs } from '@wordpress/url'
|
||||
import { WithRestAPIContext } from '../../hooks/useRestAPI'
|
||||
import { WithSnippetsListContext, useSnippetsList } from '../../hooks/useSnippetsList'
|
||||
import { SubmitSnippetAction, useSubmitSnippet } from '../../hooks/useSubmitSnippet'
|
||||
import { handleUnknownError } from '../../utils/errors'
|
||||
import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../utils/snippets/snippets'
|
||||
import { WithSnippetFormContext, useSnippetForm } from '../../hooks/useSnippetForm'
|
||||
import { ConfirmDialog } from '../common/ConfirmDialog'
|
||||
import { UpsellDialog } from '../common/UpsellDialog'
|
||||
import { EditorSidebar } from '../EditorSidebar'
|
||||
import { UpsellBanner } from '../common/UpsellBanner'
|
||||
import { SnippetTypeInput } from './fields/SnippetTypeInput'
|
||||
import { TagsEditor } from './fields/TagsEditor'
|
||||
import { CodeEditor } from './fields/CodeEditor'
|
||||
import { DescriptionEditor } from './fields/DescriptionEditor'
|
||||
import { NameInput } from './fields/NameInput'
|
||||
import { PageHeading } from './page/PageHeading'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { Snippet } from '../../types/Snippet'
|
||||
|
||||
const editFormClassName = ({ snippet, isReadOnly, isExpanded }: {
|
||||
snippet: Snippet,
|
||||
isReadOnly: boolean,
|
||||
isExpanded: boolean
|
||||
}) =>
|
||||
classnames(
|
||||
'snippet-form',
|
||||
isExpanded ? 'snippet-form-expanded' : 'snippet-form-collapsed',
|
||||
`${snippet.scope}-snippet`,
|
||||
`${getSnippetType(snippet)}-snippet`,
|
||||
`${snippet.id ? 'saved' : 'new'}-snippet`,
|
||||
`${snippet.active ? 'active' : 'inactive'}-snippet`,
|
||||
{
|
||||
'erroneous-snippet': !!snippet.code_error,
|
||||
'read-only-snippet': isReadOnly
|
||||
}
|
||||
)
|
||||
|
||||
interface ConfirmSubmitDialogProps {
|
||||
doSubmit: (action: SubmitSnippetAction | undefined) => void
|
||||
submitAction: SubmitSnippetAction | undefined
|
||||
setSubmitAction: (action: SubmitSnippetAction | undefined) => void
|
||||
validationWarning: string | undefined
|
||||
setValidationWarning: (warning: string | undefined) => void
|
||||
}
|
||||
|
||||
const ConfirmSubmitDialog: React.FC<ConfirmSubmitDialogProps> = ({
|
||||
doSubmit,
|
||||
submitAction,
|
||||
setSubmitAction,
|
||||
validationWarning,
|
||||
setValidationWarning
|
||||
}) =>
|
||||
<ConfirmDialog
|
||||
open={validationWarning !== undefined}
|
||||
title={__('Snippet incomplete', 'code-snippets')}
|
||||
confirmLabel={__('Continue', 'code-snippets')}
|
||||
onCancel={() => {
|
||||
setSubmitAction(undefined)
|
||||
setValidationWarning(undefined)
|
||||
}}
|
||||
onConfirm={() => {
|
||||
doSubmit(submitAction)
|
||||
setSubmitAction(undefined)
|
||||
setValidationWarning(undefined)
|
||||
}}
|
||||
>
|
||||
<p>{`${validationWarning} ${__('Continue?', 'code-snippets')}`}</p>
|
||||
</ConfirmDialog>
|
||||
|
||||
interface EditFormProps extends PropsWithChildren {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const EditForm: React.FC<EditFormProps> = ({ children, className }) => {
|
||||
const { submitSnippet } = useSubmitSnippet()
|
||||
const { snippet } = useSnippetForm()
|
||||
const { refreshSnippetsList } = useSnippetsList()
|
||||
|
||||
const [validationWarning, setValidationWarning] = useState<string | undefined>()
|
||||
const [submitAction, setSubmitAction] = useState<SubmitSnippetAction | undefined>()
|
||||
|
||||
const doSubmit = (action?: SubmitSnippetAction) => {
|
||||
submitSnippet(action)
|
||||
.then(response => {
|
||||
if (response && 0 !== response.id && window.CODE_SNIPPETS) {
|
||||
if (window.location.href.toString().includes(window.CODE_SNIPPETS.urls.addNew)) {
|
||||
document.title = document.title
|
||||
.replace(__('Add New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets'))
|
||||
.replace(__('Add New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets'))
|
||||
|
||||
const newUrl = addQueryArgs(window.CODE_SNIPPETS.urls.edit, { id: response.id })
|
||||
window.history.pushState({}, document.title, newUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(refreshSnippetsList)
|
||||
.catch(handleUnknownError)
|
||||
}
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
const action = Object.values(SubmitSnippetAction).find(actionName =>
|
||||
actionName === document.activeElement?.getAttribute('name'))
|
||||
|
||||
const validationWarning = validateSnippet(snippet)
|
||||
|
||||
if (validationWarning) {
|
||||
setValidationWarning(validationWarning)
|
||||
setSubmitAction(action)
|
||||
} else {
|
||||
doSubmit(action)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form id="snippet-form" method="post" onSubmit={handleSubmit} className={className}>
|
||||
{children}
|
||||
</form>
|
||||
|
||||
<ConfirmSubmitDialog {...{ doSubmit, submitAction, setSubmitAction, validationWarning, setValidationWarning }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ConditionsEditor: React.FC = () => {
|
||||
const { snippet } = useSnippetForm()
|
||||
|
||||
return isCondition(snippet)
|
||||
? <div id="snippet_conditions" className="snippet-condition-editor-container">
|
||||
<p>{__('This snippet type is not supported in this version of Code Snippets.')}</p>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
|
||||
const EditFormWrap: React.FC = () => {
|
||||
const { snippet, isReadOnly } = useSnippetForm()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="wrap">
|
||||
<p><small className="cs-back">
|
||||
{isCondition(snippet)
|
||||
? <a href={addQueryArgs(window.CODE_SNIPPETS?.urls.manage, { type: 'cond' })}>
|
||||
{__('Back to all conditions', 'code-snippets')}
|
||||
</a>
|
||||
: <a href={window.CODE_SNIPPETS?.urls.manage}>
|
||||
{__('Back to all snippets', 'code-snippets')}
|
||||
</a>}
|
||||
</small></p>
|
||||
|
||||
<PageHeading />
|
||||
|
||||
<EditForm className={editFormClassName({ snippet, isReadOnly, isExpanded })}>
|
||||
<main className="snippet-form-upper">
|
||||
<div className="snippet-name-wrapper">
|
||||
<NameInput />
|
||||
<SnippetTypeInput setIsUpgradeDialogOpen={setIsUpgradeDialogOpen} />
|
||||
</div>
|
||||
|
||||
<CodeEditor {...{ isExpanded, setIsExpanded }} />
|
||||
<ConditionsEditor />
|
||||
</main>
|
||||
|
||||
<div className="snippet-form-lower">
|
||||
<UpsellBanner />
|
||||
<DescriptionEditor />
|
||||
<TagsEditor />
|
||||
</div>
|
||||
|
||||
<EditorSidebar setIsUpgradeDialogOpen={setIsUpgradeDialogOpen} />
|
||||
</EditForm>
|
||||
|
||||
<UpsellDialog isOpen={isUpgradeDialogOpen} setIsOpen={setIsUpgradeDialogOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SnippetForm: React.FC = () =>
|
||||
<WithRestAPIContext>
|
||||
<WithSnippetsListContext>
|
||||
<WithSnippetFormContext initialSnippet={() => createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}>
|
||||
<EditFormWrap />
|
||||
</WithSnippetFormContext>
|
||||
</WithSnippetsListContext>
|
||||
</WithRestAPIContext>
|
||||
@@ -0,0 +1,94 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSubmitSnippet } from '../../../hooks/useSubmitSnippet'
|
||||
import { handleUnknownError } from '../../../utils/errors'
|
||||
import { isMacOS } from '../../../utils/screen'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { Button } from '../../common/Button'
|
||||
import { ExpandIcon } from '../../common/icons/ExpandIcon'
|
||||
import { MinimiseIcon } from '../../common/icons/MinimiseIcon'
|
||||
import { CodeEditorShortcuts } from './CodeEditorShortcuts'
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
|
||||
interface EditorTextareaProps {
|
||||
textareaRef: RefObject<HTMLTextAreaElement>
|
||||
}
|
||||
|
||||
const EditorTextarea: React.FC<EditorTextareaProps> = ({ textareaRef }) => {
|
||||
const { snippet, setSnippet } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div className="snippet-editor">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="snippet-code"
|
||||
name="snippet_code"
|
||||
value={snippet.code}
|
||||
rows={200}
|
||||
spellCheck={false}
|
||||
onChange={event => {
|
||||
setSnippet(previous => ({ ...previous, code: event.target.value }))
|
||||
}}
|
||||
/>
|
||||
<CodeEditorShortcuts editorTheme={window.CODE_SNIPPETS_EDIT?.editorTheme ?? 'default'} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CodeEditorProps {
|
||||
isExpanded: boolean
|
||||
setIsExpanded: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const CodeEditor: React.FC<CodeEditorProps> = ({ isExpanded, setIsExpanded }) => {
|
||||
const { snippet, setSnippet, codeEditorInstance, setCodeEditorInstance } = useSnippetForm()
|
||||
const { submitSnippet } = useSubmitSnippet()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setCodeEditorInstance(editorInstance => {
|
||||
if (textareaRef.current && !editorInstance) {
|
||||
editorInstance = window.wp.codeEditor.initialize(textareaRef.current)
|
||||
|
||||
editorInstance.codemirror.on('changes', instance => {
|
||||
setSnippet(previous => ({ ...previous, code: instance.getValue() }))
|
||||
})
|
||||
}
|
||||
|
||||
return editorInstance
|
||||
})
|
||||
}, [setCodeEditorInstance, textareaRef, setSnippet])
|
||||
|
||||
useEffect(() => {
|
||||
if (codeEditorInstance) {
|
||||
const extraKeys = codeEditorInstance.codemirror.getOption('extraKeys') ?? {}
|
||||
const controlKey = isMacOS() ? 'Cmd' : 'Ctrl'
|
||||
const onSave = () => {
|
||||
submitSnippet()
|
||||
.then(() => undefined)
|
||||
.catch(handleUnknownError)
|
||||
}
|
||||
|
||||
codeEditorInstance.codemirror.setOption('extraKeys', {
|
||||
...'object' === typeof extraKeys ? extraKeys : undefined,
|
||||
[`${controlKey}-S`]: onSave,
|
||||
[`${controlKey}-Enter`]: onSave
|
||||
})
|
||||
}
|
||||
}, [submitSnippet, codeEditorInstance, snippet])
|
||||
|
||||
return (
|
||||
<div className="snippet-code-container">
|
||||
<div className="above-snippet-code">
|
||||
<h2><label htmlFor="snippet-code">{__('Snippet Content', 'code-snippets')}</label></h2>
|
||||
|
||||
<Button small className="expand-editor-button" onClick={() => setIsExpanded(current => !current)}>
|
||||
{isExpanded ? <MinimiseIcon /> : <ExpandIcon />}
|
||||
{isExpanded ? __('Minimize', 'code-snippets') : __('Expand', 'code-snippets')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EditorTextarea textareaRef={textareaRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { __, _x } from '@wordpress/i18n'
|
||||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
import { KEYBOARD_KEYS } from '../../../types/KeyboardShortcut'
|
||||
import { isMacOS } from '../../../utils/screen'
|
||||
import type { KeyboardKey, KeyboardShortcut } from '../../../types/KeyboardShortcut'
|
||||
|
||||
const shortcuts: Record<string, KeyboardShortcut> = {
|
||||
saveChanges: {
|
||||
label: __('Save changes', 'code-snippets'),
|
||||
mod: 'Cmd',
|
||||
key: 'S'
|
||||
},
|
||||
selectAll: {
|
||||
label: __('Select all', 'code-snippets'),
|
||||
mod: 'Cmd',
|
||||
key: 'A'
|
||||
},
|
||||
beginSearch: {
|
||||
label: __('Begin searching', 'code-snippets'),
|
||||
mod: 'Cmd',
|
||||
key: 'F'
|
||||
},
|
||||
findNext: {
|
||||
label: __('Find next', 'code-snippets'),
|
||||
mod: 'Cmd',
|
||||
key: 'G'
|
||||
},
|
||||
findPrevious: {
|
||||
label: __('Find previous', 'code-snippets'),
|
||||
mod: ['Shift', 'Cmd'],
|
||||
key: 'G'
|
||||
},
|
||||
replace: {
|
||||
label: __('Replace', 'code-snippets'),
|
||||
mod: ['Shift', 'Cmd'],
|
||||
key: 'F'
|
||||
},
|
||||
replaceAll: {
|
||||
label: __('Replace all', 'code-snippets'),
|
||||
mod: ['Shift', 'Cmd', 'Option'],
|
||||
key: 'R'
|
||||
},
|
||||
search: {
|
||||
label: __('Persistent search', 'code-snippets'),
|
||||
mod: 'Alt',
|
||||
key: 'F'
|
||||
},
|
||||
toggleComment: {
|
||||
label: __('Toggle comment', 'code-snippets'),
|
||||
mod: 'Cmd',
|
||||
key: '/'
|
||||
},
|
||||
swapLineUp: {
|
||||
label: __('Swap line up', 'code-snippets'),
|
||||
mod: 'Option',
|
||||
key: 'Up'
|
||||
},
|
||||
swapLineDown: {
|
||||
label: __('Swap line down', 'code-snippets'),
|
||||
mod: 'Option',
|
||||
key: 'Down'
|
||||
},
|
||||
autoIndent: {
|
||||
label: __('Auto-indent current line or selection', 'code-snippets'),
|
||||
mod: 'Shift',
|
||||
key: 'Tab'
|
||||
}
|
||||
}
|
||||
|
||||
const SEP = _x('-', 'keyboard shortcut separator', 'code-snippets')
|
||||
|
||||
const ModifierKey: React.FC<{ modifier: KeyboardKey }> = ({ modifier }) => {
|
||||
switch (modifier) {
|
||||
case 'Ctrl':
|
||||
case 'Cmd':
|
||||
return (
|
||||
<>
|
||||
<kbd className="pc-key">{KEYBOARD_KEYS.Ctrl}</kbd>
|
||||
<kbd className="mac-key">{KEYBOARD_KEYS.Cmd}</kbd>
|
||||
{SEP}
|
||||
</>
|
||||
)
|
||||
|
||||
case 'Option':
|
||||
return (
|
||||
<span className="mac-key">
|
||||
<kbd className="mac-key">{KEYBOARD_KEYS.Option}</kbd>{SEP}
|
||||
</span>
|
||||
)
|
||||
|
||||
default:
|
||||
return <><kbd>{KEYBOARD_KEYS[modifier]}</kbd>{SEP}</>
|
||||
}
|
||||
}
|
||||
|
||||
export interface CodeEditorShortcutsProps {
|
||||
editorTheme: string
|
||||
}
|
||||
|
||||
export const CodeEditorShortcuts: React.FC<CodeEditorShortcutsProps> = ({ editorTheme }) =>
|
||||
<div className="snippet-editor-help tooltip tooltip-inline tooltip-start">
|
||||
<span className={`dashicons dashicons-editor-help cm-s-${editorTheme}`}></span>
|
||||
|
||||
<div className={classnames('tooltip-content', { 'platform-mac': isMacOS() })}>
|
||||
<table>
|
||||
<tbody>
|
||||
{Object.entries(shortcuts).map(([name, { label, mod, key }]) =>
|
||||
<tr key={name}>
|
||||
<td>{label}</td>
|
||||
<td>
|
||||
{(Array.isArray(mod) ? mod : [mod]).map(modifier =>
|
||||
<span key={modifier}>
|
||||
<ModifierKey modifier={modifier} />
|
||||
</span>
|
||||
)}
|
||||
<kbd>{KEYBOARD_KEYS[key]}</kbd>
|
||||
</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import domReady from '@wordpress/dom-ready'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
|
||||
export const EDITOR_ID = 'snippet_description'
|
||||
|
||||
const TOOLBAR_BUTTONS = [
|
||||
[
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'strikethrough',
|
||||
'blockquote',
|
||||
'bullist',
|
||||
'numlist',
|
||||
'alignleft',
|
||||
'aligncenter',
|
||||
'alignright',
|
||||
'link',
|
||||
'wp_adv',
|
||||
'code_snippets'
|
||||
],
|
||||
[
|
||||
'formatselect',
|
||||
'forecolor',
|
||||
'pastetext',
|
||||
'removeformat',
|
||||
'charmap',
|
||||
'outdent',
|
||||
'indent',
|
||||
'undo',
|
||||
'redo',
|
||||
'spellchecker'
|
||||
]
|
||||
]
|
||||
|
||||
const initializeEditor = (onChange: (content: string) => void) => {
|
||||
window.wp.editor?.initialize(EDITOR_ID, {
|
||||
mediaButtons: window.CODE_SNIPPETS_EDIT?.descEditorOptions.mediaButtons,
|
||||
quicktags: true,
|
||||
tinymce: {
|
||||
toolbar: TOOLBAR_BUTTONS.map(line => line.join(' ')),
|
||||
setup: editor => {
|
||||
editor.on('change', () => onChange(editor.getContent()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const DescriptionEditorTextarea: React.FC = () => {
|
||||
const { snippet, setSnippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
const handleChange = useCallback(
|
||||
(desc: string) => setSnippet(previous => ({ ...previous, desc })),
|
||||
[setSnippet]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
domReady(() => initializeEditor(handleChange))
|
||||
}, [handleChange])
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={EDITOR_ID}
|
||||
className="wp-editor-area"
|
||||
onChange={event => handleChange(event.target.value)}
|
||||
autoComplete="off"
|
||||
disabled={isReadOnly}
|
||||
rows={window.CODE_SNIPPETS_EDIT?.descEditorOptions.rows}
|
||||
cols={40}
|
||||
value={snippet.desc}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const DescriptionEditor: React.FC = () =>
|
||||
window.CODE_SNIPPETS_EDIT?.enableDescription
|
||||
? <div className="snippet-description-container">
|
||||
<h2>
|
||||
<label htmlFor={EDITOR_ID}>
|
||||
{__('Description', 'code-snippets')}
|
||||
</label>
|
||||
</h2>
|
||||
|
||||
<DescriptionEditorTextarea />
|
||||
</div>
|
||||
: null
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
|
||||
export const NameInput: React.FC = () => {
|
||||
const { snippet, setSnippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<div id="titlediv">
|
||||
<div id="titlewrap">
|
||||
<label htmlFor="title" className="screen-reader-text">
|
||||
{__('Name', 'code-snippets')}
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="snippet_name"
|
||||
autoComplete="off"
|
||||
value={snippet.name}
|
||||
disabled={isReadOnly}
|
||||
placeholder={__('Enter snippet title', 'code-snippets')}
|
||||
onChange={event =>
|
||||
setSnippet(previous => ({ ...previous, name: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import React from 'react'
|
||||
import Select from 'react-select'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { SNIPPET_TYPE_SCOPES } from '../../../types/Snippet'
|
||||
import { getSnippetType, isCondition } from '../../../utils/snippets/snippets'
|
||||
import type { SnippetCodeScope } from '../../../types/Snippet'
|
||||
import type { SelectOption } from '../../../types/SelectOption'
|
||||
|
||||
const SCOPE_ICONS: Record<SnippetCodeScope, string> = {
|
||||
'global': 'admin-site',
|
||||
'admin': 'admin-tools',
|
||||
'front-end': 'admin-appearance',
|
||||
'single-use': 'clock',
|
||||
'content': 'shortcode',
|
||||
'head-content': 'editor-code',
|
||||
'footer-content': 'editor-code',
|
||||
'admin-css': 'dashboard',
|
||||
'site-css': 'admin-customizer',
|
||||
'site-head-js': 'media-code',
|
||||
'site-footer-js': 'media-code'
|
||||
}
|
||||
|
||||
const SCOPE_DESCRIPTIONS: Record<SnippetCodeScope, string> = {
|
||||
'global': __('Run everywhere', 'code-snippets'),
|
||||
'admin': __('Only run in administration area', 'code-snippets'),
|
||||
'front-end': __('Only run on site front-end', 'code-snippets'),
|
||||
'single-use': __('Only run once', 'code-snippets'),
|
||||
'content': __('Where inserted in editor', 'code-snippets'),
|
||||
'head-content': __('In site <head> section', 'code-snippets'),
|
||||
'footer-content': __('In site footer (end of <body>)', 'code-snippets'),
|
||||
'site-css': __('Site front-end', 'code-snippets'),
|
||||
'admin-css': __('Administration area', 'code-snippets'),
|
||||
'site-footer-js': __('In site footer (end of <body>)', 'code-snippets'),
|
||||
'site-head-js': __('In site <head> section', 'code-snippets')
|
||||
}
|
||||
|
||||
export const SnippetLocationInput: React.FC = () => {
|
||||
const { snippet, setSnippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
const options: SelectOption<SnippetCodeScope>[] = SNIPPET_TYPE_SCOPES[getSnippetType(snippet)]
|
||||
.filter(scope => 'condition' !== scope)
|
||||
.map(scope => ({
|
||||
key: scope,
|
||||
value: scope,
|
||||
label: SCOPE_DESCRIPTIONS[scope]
|
||||
}))
|
||||
|
||||
return isCondition(snippet)
|
||||
? null
|
||||
: <div className="block-form-field">
|
||||
<h4><label htmlFor="snippet-location">{__('Location', 'code-snippets')}</label></h4>
|
||||
<Select
|
||||
inputId="snippet-location"
|
||||
className="code-snippets-select code-snippets-select-location"
|
||||
options={options}
|
||||
isDisabled={isReadOnly}
|
||||
styles={{
|
||||
menu: provided => ({ ...provided, zIndex: 9999 }),
|
||||
input: provided => ({ ...provided, ':focus': { boxShadow: 'none' } })
|
||||
}}
|
||||
value={options.find(option => option.value === snippet.scope)}
|
||||
formatOptionLabel={({ label, value }) =>
|
||||
<>
|
||||
<span className={`dashicons dashicons-${SCOPE_ICONS[value]}`}></span>{` ${label}`}
|
||||
</>
|
||||
}
|
||||
onChange={option =>
|
||||
option?.value && setSnippet(previous => ({ ...previous, scope: option.value }))}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { __, _x } from '@wordpress/i18n'
|
||||
import Select from 'react-select'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { SNIPPET_TYPE_SCOPES } from '../../../types/Snippet'
|
||||
import { isLicensed } from '../../../utils/screen'
|
||||
import { getSnippetType, isProType } from '../../../utils/snippets/snippets'
|
||||
import { Badge } from '../../common/Badge'
|
||||
import type { FormatOptionLabelContext } from 'react-select'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { SnippetCodeType, SnippetType } from '../../../types/Snippet'
|
||||
import type { SelectOption } from '../../../types/SelectOption'
|
||||
import type { EditorConfiguration } from 'codemirror'
|
||||
|
||||
export interface SnippetTypeInputProps {
|
||||
setIsUpgradeDialogOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const EDITOR_MODES: Record<SnippetCodeType, string> = {
|
||||
css: 'text/css',
|
||||
js: 'javascript',
|
||||
php: 'text/x-php',
|
||||
html: 'application/x-httpd-php'
|
||||
}
|
||||
|
||||
const OPTIONS: SelectOption<SnippetType>[] = [
|
||||
{ value: 'php', label: __('Functions', 'code-snippets') },
|
||||
{ value: 'html', label: __('Content', 'code-snippets') },
|
||||
{ value: 'css', label: __('Styles', 'code-snippets') },
|
||||
{ value: 'js', label: __('Scripts', 'code-snippets') },
|
||||
{ value: 'cond', label: __('Conditions', 'code-snippets') }
|
||||
]
|
||||
|
||||
interface SnippetTypeOptionProps {
|
||||
option: SelectOption<SnippetType>
|
||||
context: FormatOptionLabelContext
|
||||
}
|
||||
|
||||
const SnippetTypeOption: React.FC<SnippetTypeOptionProps> = ({ option: { value, label }, context }) =>
|
||||
<div className={classnames('snippet-type-option', { 'inverted-badges': isProType(value) && !isLicensed() })}>
|
||||
{'menu' === context
|
||||
? <div>
|
||||
{label}
|
||||
{isProType(value) && !isLicensed()
|
||||
? <Badge name="pro" small>{_x('Pro', 'Upgrade to Pro', 'code-snippets')}</Badge>
|
||||
: null}
|
||||
</div>
|
||||
: null}
|
||||
<Badge name={value} />
|
||||
</div>
|
||||
|
||||
export const SnippetTypeInput: React.FC<SnippetTypeInputProps> = ({ setIsUpgradeDialogOpen }) => {
|
||||
const { snippet, setSnippet, codeEditorInstance, isReadOnly } = useSnippetForm()
|
||||
const snippetType = getSnippetType(snippet)
|
||||
|
||||
useEffect(() => {
|
||||
if (codeEditorInstance) {
|
||||
const codeEditor = codeEditorInstance.codemirror
|
||||
|
||||
codeEditor.setOption('lint' as keyof EditorConfiguration, 'php' === snippetType || 'css' === snippetType)
|
||||
|
||||
if ('cond' !== snippetType && EDITOR_MODES[snippetType]) {
|
||||
codeEditor.setOption('mode', EDITOR_MODES[snippetType])
|
||||
codeEditor.refresh()
|
||||
}
|
||||
}
|
||||
}, [codeEditorInstance, snippetType])
|
||||
|
||||
return (
|
||||
<div className="snippet-type-container">
|
||||
<label htmlFor="snippet-type-select-input" className="screen-reader-text">
|
||||
{__('Snippet Type', 'code-snippets')}
|
||||
</label>
|
||||
<Select
|
||||
inputId="snippet-type-select-input"
|
||||
className="code-snippets-select"
|
||||
isDisabled={isReadOnly}
|
||||
options={0 === snippet.id ? OPTIONS : OPTIONS.filter(option => 'cond' !== option.value)}
|
||||
menuPlacement="bottom"
|
||||
styles={{
|
||||
menu: provided => ({
|
||||
...provided,
|
||||
zIndex: 9999,
|
||||
width: 'max-content',
|
||||
minWidth: '100%'
|
||||
}),
|
||||
input: provided => ({ ...provided, boxShadow: 'none' })
|
||||
}}
|
||||
value={OPTIONS.find(option => option.value === snippetType)}
|
||||
formatOptionLabel={(data, meta) =>
|
||||
<SnippetTypeOption option={data} context={meta.context} />}
|
||||
onChange={option => {
|
||||
if (option && isProType(option.value) && !isLicensed()) {
|
||||
setIsUpgradeDialogOpen(true)
|
||||
} else if (option) {
|
||||
setSnippet(previous => ({
|
||||
...previous,
|
||||
scope: SNIPPET_TYPE_SCOPES[option.value][0]
|
||||
}))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { FormTokenField } from '@wordpress/components'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
|
||||
const options = window.CODE_SNIPPETS_EDIT?.tagOptions
|
||||
|
||||
export const TagsEditor: React.FC = () => {
|
||||
const { snippet, setSnippet, isReadOnly } = useSnippetForm()
|
||||
|
||||
return options?.enabled
|
||||
? <div className="snippet-tags-container">
|
||||
<h3><label htmlFor="components-form-token-input-0">{__('Snippet Tags', 'code-snippets')}</label></h3>
|
||||
|
||||
<FormTokenField
|
||||
label=""
|
||||
value={snippet.tags}
|
||||
disabled={isReadOnly}
|
||||
suggestions={options.availableTags}
|
||||
tokenizeOnBlur
|
||||
tokenizeOnSpace={!options.allowSpaces}
|
||||
onChange={tokens => {
|
||||
setSnippet(previous => ({
|
||||
...previous,
|
||||
tags: tokens.map(token => 'string' === typeof token ? token : token.value)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
1
plugins/code-snippets/js/components/SnippetForm/index.ts
Normal file
1
plugins/code-snippets/js/components/SnippetForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './SnippetForm'
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createInterpolateElement } from '@wordpress/element'
|
||||
import React from 'react'
|
||||
import { __, sprintf } from '@wordpress/i18n'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { DismissibleNotice } from '../../common/DismissableNotice'
|
||||
|
||||
export const Notices: React.FC = () => {
|
||||
const { currentNotice, setCurrentNotice, snippet, setSnippet } = useSnippetForm()
|
||||
|
||||
return <>
|
||||
{currentNotice
|
||||
? <DismissibleNotice className={currentNotice[0]} onDismiss={() => setCurrentNotice(undefined)}>
|
||||
<p>{createInterpolateElement(currentNotice[1], { strong: <strong /> })}</p>
|
||||
</DismissibleNotice>
|
||||
: null}
|
||||
|
||||
{snippet.code_error
|
||||
? <DismissibleNotice
|
||||
className="notice-error"
|
||||
onDismiss={() => setSnippet(previous => ({ ...previous, code_error: null }))}
|
||||
>
|
||||
<p>
|
||||
<strong>{sprintf(
|
||||
// translators: %d: line number.
|
||||
__('Snippet automatically deactivated due to an error on line %d:', 'code-snippets'),
|
||||
snippet.code_error[1]
|
||||
)}</strong>
|
||||
|
||||
<blockquote>{snippet.code_error[0]}</blockquote>
|
||||
</p>
|
||||
</DismissibleNotice>
|
||||
: null}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { __, _x } from '@wordpress/i18n'
|
||||
import React from 'react'
|
||||
import { useSnippetForm } from '../../../hooks/useSnippetForm'
|
||||
import { createSnippetObject } from '../../../utils/snippets/snippets'
|
||||
import type { Snippet } from '../../../types/Snippet'
|
||||
|
||||
const OPTIONS = window.CODE_SNIPPETS_EDIT
|
||||
|
||||
const getAddNewHeading = (snippet: Snippet): string =>
|
||||
'condition' === snippet.scope
|
||||
? __('Add New Condition', 'code-snippets')
|
||||
: __('Add New Snippet', 'code-snippets')
|
||||
|
||||
export const PageHeading: React.FC = () => {
|
||||
const { snippet, updateSnippet, setCurrentNotice } = useSnippetForm()
|
||||
|
||||
return (
|
||||
<h1>
|
||||
{snippet.id
|
||||
? <>
|
||||
{`${'condition' === snippet.scope
|
||||
? __('Edit Condition', 'code-snippets')
|
||||
: __('Edit Snippet', 'code-snippets')} `}
|
||||
|
||||
<a
|
||||
href={window.CODE_SNIPPETS?.urls.addNew}
|
||||
className="page-title-action"
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
updateSnippet(({ scope }) => createSnippetObject({ scope }))
|
||||
setCurrentNotice(undefined)
|
||||
|
||||
window.document.title = window.document.title
|
||||
.replace(__('Edit Snippet', 'code-snippets'), getAddNewHeading(snippet))
|
||||
.replace(__('Edit Condition', 'code-snippets'), getAddNewHeading(snippet))
|
||||
|
||||
window.history.pushState({}, '', window.CODE_SNIPPETS?.urls.addNew)
|
||||
}}
|
||||
>
|
||||
{_x('Add New', 'snippet', 'code-snippets')}
|
||||
</a>
|
||||
</>
|
||||
: getAddNewHeading(snippet)}
|
||||
|
||||
{OPTIONS?.pageTitleActions && Object.entries(OPTIONS.pageTitleActions).map(([label, url]) =>
|
||||
<>
|
||||
<a key={label} href={url} className="page-title-action">{label}</a>{' '}
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
27
plugins/code-snippets/js/components/common/Badge.tsx
Normal file
27
plugins/code-snippets/js/components/common/Badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SnippetType } from '../../types/Snippet'
|
||||
|
||||
export type BadgeName = SnippetType | 'core' | 'pro' | 'ai' | 'cloud' | 'bundles' | 'cloud_search' | 'beta'
|
||||
|
||||
const badgeIcons: Partial<Record<BadgeName, string>> = {
|
||||
cond: 'randomize',
|
||||
cloud: 'cloud',
|
||||
bundles: 'screenoptions',
|
||||
cloud_search: 'search'
|
||||
}
|
||||
|
||||
export interface BadgeProps {
|
||||
name: BadgeName
|
||||
small?: boolean
|
||||
inverted?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const Badge: React.FC<BadgeProps> = ({ name, small, inverted, children }) =>
|
||||
<span className={classnames('badge', `${name}-badge`, { 'small-badge': small, 'inverted-badge': inverted })}>
|
||||
{badgeIcons[name]
|
||||
? <span className={`dashicons dashicons-${badgeIcons[name]}`} />
|
||||
: children ?? name}
|
||||
</span>
|
||||
49
plugins/code-snippets/js/components/common/Button.tsx
Normal file
49
plugins/code-snippets/js/components/common/Button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import type { ButtonHTMLAttributes } from 'react'
|
||||
|
||||
export interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'id' | 'name'> {
|
||||
id?: string
|
||||
name?: string
|
||||
primary?: boolean
|
||||
secondary?: boolean
|
||||
small?: boolean
|
||||
large?: boolean
|
||||
link?: boolean
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
name,
|
||||
primary = false,
|
||||
secondary = false,
|
||||
small = false,
|
||||
large = false,
|
||||
link = false,
|
||||
type = 'button',
|
||||
onClick,
|
||||
...props
|
||||
}) =>
|
||||
<button
|
||||
id={id ?? name}
|
||||
name={name}
|
||||
type={type}
|
||||
{...props}
|
||||
onClick={event => {
|
||||
if (onClick) {
|
||||
event.preventDefault()
|
||||
onClick(event)
|
||||
}
|
||||
}}
|
||||
className={classnames('button', className, {
|
||||
'button-primary': primary,
|
||||
'button-secondary': secondary,
|
||||
'button-large': large,
|
||||
'button-small': small,
|
||||
'button-link': link
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
49
plugins/code-snippets/js/components/common/ConfirmDialog.tsx
Normal file
49
plugins/code-snippets/js/components/common/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { Button, Flex, Modal } from '@wordpress/components'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open?: boolean
|
||||
title: string
|
||||
onConfirm?: VoidFunction
|
||||
onCancel: VoidFunction
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
children?: ReactNode,
|
||||
confirmButtonClassName?: string
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
open,
|
||||
title,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
confirmLabel = __('OK', 'code-snippets'),
|
||||
cancelLabel = __('Cancel', 'code-snippets'),
|
||||
confirmButtonClassName
|
||||
}) =>
|
||||
open
|
||||
? <Modal
|
||||
title={title}
|
||||
onRequestClose={onCancel}
|
||||
closeButtonLabel={cancelLabel}
|
||||
isDismissible={true}
|
||||
onKeyDown={event => {
|
||||
if ('Enter' === event.key) {
|
||||
onConfirm?.()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Flex direction="row" justify="flex-end">
|
||||
<Button variant="tertiary" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onConfirm} className={confirmButtonClassName}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
: null
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Spinner } from '@wordpress/components'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import React, { useState } from 'react'
|
||||
import { Button } from './Button'
|
||||
import { CopyIcon } from './icons/CopyIcon'
|
||||
import type { ButtonProps } from './Button'
|
||||
|
||||
const TIMEOUT = 1500
|
||||
|
||||
enum Status {
|
||||
INITIAL,
|
||||
PROGRESSING,
|
||||
SUCCESS,
|
||||
ERROR
|
||||
}
|
||||
|
||||
interface StatusIconProps {
|
||||
status: Status
|
||||
}
|
||||
|
||||
const StatusIcon: React.FC<StatusIconProps> = ({ status }) => {
|
||||
switch (status) {
|
||||
case Status.INITIAL:
|
||||
return <CopyIcon />
|
||||
case Status.PROGRESSING:
|
||||
return <span className="spinner-wrapper"><Spinner /></span>
|
||||
case Status.SUCCESS:
|
||||
return <span className="dashicons dashicons-yes"></span>
|
||||
case Status.ERROR:
|
||||
return <span className="dashicons dashicons-warning"></span>
|
||||
}
|
||||
}
|
||||
|
||||
export interface CopyToClipboardButtonProps extends ButtonProps {
|
||||
text: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const CopyToClipboardButton: React.FC<CopyToClipboardButtonProps> = ({
|
||||
text,
|
||||
timeout = TIMEOUT,
|
||||
...props
|
||||
}) => {
|
||||
const [status, setStatus] = useState(Status.INITIAL)
|
||||
const clipboard = window.navigator.clipboard as Clipboard | undefined
|
||||
|
||||
const handleClick = () => {
|
||||
setStatus(Status.PROGRESSING)
|
||||
|
||||
clipboard?.writeText(text)
|
||||
.then(() => {
|
||||
setStatus(Status.SUCCESS)
|
||||
setTimeout(() => setStatus(Status.INITIAL), timeout)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to copy text to clipboard.', error)
|
||||
setStatus(Status.ERROR)
|
||||
})
|
||||
}
|
||||
|
||||
return clipboard && window.isSecureContext
|
||||
? <Button
|
||||
className="code-snippets-copy-text"
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<StatusIcon status={status} />
|
||||
{__('Copy', 'code-snippets')}
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import classnames from 'classnames'
|
||||
import React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface DismissibleNoticeProps {
|
||||
className?: classnames.Argument
|
||||
onDismiss: VoidFunction
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const DismissibleNotice: React.FC<DismissibleNoticeProps> = ({ className, onDismiss, children }) =>
|
||||
<div id="message" className={classnames('notice fade is-dismissible', className)}>
|
||||
<>{children}</>
|
||||
|
||||
<button type="button" className="notice-dismiss" onClick={event => {
|
||||
event.preventDefault()
|
||||
onDismiss()
|
||||
}}>
|
||||
<span className="screen-reader-text">{__('Dismiss notice.', 'code-snippets')}</span>
|
||||
</button>
|
||||
</div>
|
||||
46
plugins/code-snippets/js/components/common/SubmitButton.tsx
Normal file
46
plugins/code-snippets/js/components/common/SubmitButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
|
||||
export interface SubmitButtonProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'id' | 'name' | 'value'> {
|
||||
id?: string
|
||||
name?: string
|
||||
primary?: boolean
|
||||
small?: boolean
|
||||
large?: boolean
|
||||
wrap?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
export const SubmitButton: React.FC<SubmitButtonProps> = ({
|
||||
id,
|
||||
text,
|
||||
name = 'submit',
|
||||
primary,
|
||||
small,
|
||||
large,
|
||||
wrap,
|
||||
className,
|
||||
...inputProps
|
||||
}) => {
|
||||
const button =
|
||||
<input
|
||||
id={id ?? name}
|
||||
type="submit"
|
||||
name={name}
|
||||
value={text ?? __('Save Changes', 'code-snippets')}
|
||||
className={classnames(
|
||||
'button',
|
||||
{
|
||||
'button-primary': primary,
|
||||
'button-small': small,
|
||||
'button-large': large
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
||||
return wrap ? <p className="submit">{button}</p> : button
|
||||
}
|
||||
25
plugins/code-snippets/js/components/common/Tooltip.tsx
Normal file
25
plugins/code-snippets/js/components/common/Tooltip.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import classnames from 'classnames'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export interface TooltipProps {
|
||||
block?: boolean
|
||||
inline?: boolean
|
||||
start?: boolean
|
||||
end?: boolean
|
||||
icon?: ReactNode
|
||||
children: ReactNode
|
||||
className?: classnames.Argument
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({ block, inline, start, end, icon, className, children }) =>
|
||||
<div className={classnames(
|
||||
'tooltip',
|
||||
{ 'tooltip-block': block, 'tooltip-inline': inline, 'tooltip-start': start, 'tooltip-end': end },
|
||||
className
|
||||
)}>
|
||||
{icon ?? <span className="dashicons dashicons-editor-help"></span>}
|
||||
<div className="tooltip-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
37
plugins/code-snippets/js/components/common/UpsellBanner.tsx
Normal file
37
plugins/code-snippets/js/components/common/UpsellBanner.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ExternalLink } from '@wordpress/components'
|
||||
import { createInterpolateElement } from '@wordpress/element'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import React, { useState } from 'react'
|
||||
import { isLicensed } from '../../utils/screen'
|
||||
import { Button } from './Button'
|
||||
|
||||
export const UpsellBanner = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
|
||||
return isDismissed || isLicensed() || window.CODE_SNIPPETS_EDIT?.hideUpsell
|
||||
? null
|
||||
: <div className="code-snippets-upsell-banner">
|
||||
<img
|
||||
src={`${window.CODE_SNIPPETS?.urls.plugin}/assets/icon.svg`}
|
||||
alt={__('Code Snippets logo', 'code-snippets')}
|
||||
height="34"
|
||||
/>
|
||||
<p>
|
||||
{createInterpolateElement(
|
||||
__('Unlock <strong>cloud sync, snippet conditions, AI features</strong> and much more with Code Snippets Pro.', 'code-snippets'),
|
||||
{ strong: <strong /> }
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ExternalLink
|
||||
className="button button-primary button-large"
|
||||
href="https://codesnippets.pro/pricing/"
|
||||
>
|
||||
{__('Get Started', 'code-snippets')}
|
||||
</ExternalLink>
|
||||
|
||||
<Button small link onClick={() => setIsDismissed(true)}>
|
||||
<span className="dashicons dashicons-no-alt"></span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
62
plugins/code-snippets/js/components/common/UpsellDialog.tsx
Normal file
62
plugins/code-snippets/js/components/common/UpsellDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { createInterpolateElement } from '@wordpress/element'
|
||||
import React from 'react'
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { Modal } from '@wordpress/components'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
|
||||
export interface UpsellDialogProps {
|
||||
isOpen: boolean
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export const UpsellDialog: React.FC<UpsellDialogProps> = ({ isOpen, setIsOpen }) =>
|
||||
isOpen
|
||||
? <Modal
|
||||
title=""
|
||||
className="code-snippets-upsell-dialog"
|
||||
onRequestClose={() => {
|
||||
setIsOpen(false)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${window.CODE_SNIPPETS?.urls.plugin}/assets/icon.svg`}
|
||||
alt={__('Code Snippets logo', 'code-snippets')}
|
||||
/>
|
||||
|
||||
<h1>
|
||||
{createInterpolateElement(
|
||||
__('Unlock all cloud sync features and many more, with <span>Code Snippets Pro</span>', 'code-snippets'),
|
||||
{ span: <span /> }
|
||||
)}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
{createInterpolateElement(
|
||||
__('With Code Snippets Pro you can connect your WordPress sites to the code snippets cloud platform and be able to <strong>backup, synchronise, collaborate, and deploy</strong> your snippets from one central location.', 'code-snippets'),
|
||||
{ strong: <strong /> }
|
||||
)}
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://codesnippets.pro/pricing/"
|
||||
className="button button-primary button-large"
|
||||
rel="noreferrer" target="_blank"
|
||||
>
|
||||
{__('Explore Code Snippets Pro', 'code-snippets')}
|
||||
</a>
|
||||
|
||||
<h2>{__("Here's what else you get with Pro:", 'code-snippets')}</h2>
|
||||
<ul>
|
||||
<li>{__('Create, explain and verify snippets with AI', 'code-snippets')}</li>
|
||||
<li>{__('Control when snippets run with Conditions', 'code-snippets')}</li>
|
||||
<li>{__('CSS stylesheet snippets', 'code-snippets')}</li>
|
||||
<li>{__('Minified JavaScript snippets', 'code-snippets')}</li>
|
||||
<li>{__('Editor blocks and Elementor widgets', 'code-snippets')}</li>
|
||||
<li>{__('Cloud sync and backup', 'code-snippets')}</li>
|
||||
<li>{__('Cloud share and deploy', 'code-snippets')}</li>
|
||||
<li>{__('Cloud bundles and teams', 'code-snippets')}</li>
|
||||
<li>{__('WP-CLI commands', 'code-snippets')}</li>
|
||||
<li>{__('And much more!', 'code-snippets')}</li>
|
||||
</ul>
|
||||
</Modal>
|
||||
: null
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
export const CopyIcon = () =>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 6V4.5C12 4.10218 11.842 3.72064 11.5607 3.43934C11.2794 3.15804 10.8978 3 10.5 3H4.5C4.10218 3 3.72064 3.15804 3.43934 3.43934C3.15804 3.72064 3 4.10218 3 4.5V10.5C3 10.8978 3.15804 11.2794 3.43934 11.5607C3.72064 11.842 4.10218 12 4.5 12H6M6 7.5C6 7.10218 6.15804 6.72065 6.43934 6.43934C6.72065 6.15804 7.10218 6 7.5 6H13.5C13.8978 6 14.2794 6.15804 14.5607 6.43934C14.842 6.72065 15 7.10218 15 7.5V13.5C15 13.8978 14.842 14.2794 14.5607 14.5607C14.2794 14.842 13.8978 15 13.5 15H7.5C7.10218 15 6.72065 14.842 6.43934 14.5607C6.15804 14.2794 6 13.8978 6 13.5V7.5Z"
|
||||
stroke="#F0F0F0"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
|
||||
export const ExpandIcon = () =>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 3H15V6" />
|
||||
<path d="M10.5 7.5L15 3L10.5 7.5Z" />
|
||||
<path d="M6 15H3V12" />
|
||||
<path d="M3 15L7.5 10.5L3 15Z" />
|
||||
<path d="M12 15H15V12" />
|
||||
<path d="M10.5 10.5L15 15L10.5 10.5Z" />
|
||||
<path d="M6 3H3V6" />
|
||||
<path d="M3 3L7.5 7.5L3 3Z" />
|
||||
<path
|
||||
d="M12 3H15M15 3V6M15 3L10.5 7.5M6 15H3M3 15V12M3 15L7.5 10.5M12 15H15M15 15V12M15 15L10.5 10.5M6 3H3M3 3V6M3 3L7.5 7.5"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react'
|
||||
|
||||
export const MinimiseIcon = () =>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.75 6.75H6.75V3.75" />
|
||||
<path d="M2.25 2.25L6.75 6.75L2.25 2.25Z" />
|
||||
<path d="M3.75 11.25H6.75V14.25" />
|
||||
<path d="M2.25 15.75L6.75 11.25L2.25 15.75Z" />
|
||||
<path d="M14.25 6.75H11.25V3.75" />
|
||||
<path d="M11.25 6.75L15.75 2.25L11.25 6.75Z" />
|
||||
<path d="M14.25 11.25H11.25V14.25" />
|
||||
<path d="M11.25 11.25L15.75 15.75L11.25 11.25Z" />
|
||||
<path
|
||||
d="M3.75 6.75H6.75M6.75 6.75V3.75M6.75 6.75L2.25 2.25M3.75 11.25H6.75M6.75 11.25V14.25M6.75 11.25L2.25 15.75M14.25 6.75H11.25M11.25 6.75V3.75M11.25 6.75L15.75 2.25M14.25 11.25H11.25M11.25 11.25V14.25M11.25 11.25L15.75 15.75"
|
||||
stroke="currentcolor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
12
plugins/code-snippets/js/edit.tsx
Normal file
12
plugins/code-snippets/js/edit.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { SnippetForm } from './components/SnippetForm'
|
||||
|
||||
const container = document.getElementById('edit-snippet-form-container')
|
||||
|
||||
if (container) {
|
||||
const root = createRoot(container)
|
||||
root.render(<SnippetForm />)
|
||||
} else {
|
||||
console.error('Could not find snippet edit form container.')
|
||||
}
|
||||
21
plugins/code-snippets/js/editor.ts
Normal file
21
plugins/code-snippets/js/editor.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineMode, getMode, registerHelper } from 'codemirror'
|
||||
import { Linter } from './utils/Linter'
|
||||
import type { EditorConfiguration, ModeSpec } from 'codemirror'
|
||||
|
||||
interface ModeSpecOptions {
|
||||
startOpen: boolean
|
||||
}
|
||||
|
||||
const mode: ModeSpec<ModeSpecOptions> = {
|
||||
name: 'application/x-httpd-php',
|
||||
startOpen: true
|
||||
}
|
||||
|
||||
defineMode('php-snippet', (config: EditorConfiguration) => getMode(config, mode))
|
||||
|
||||
registerHelper('lint', 'php', (text: string) => {
|
||||
const linter = new Linter(text)
|
||||
linter.lint()
|
||||
|
||||
return linter.annotations
|
||||
})
|
||||
39
plugins/code-snippets/js/hooks/useAxios.ts
Normal file
39
plugins/code-snippets/js/hooks/useAxios.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosResponse, CreateAxiosDefaults } from 'axios'
|
||||
|
||||
export interface AxiosAPI {
|
||||
get: <T>(url: string) => Promise<AxiosResponse<T, never>>
|
||||
post: <T, D>(url: string, data?: D) => Promise<AxiosResponse<T, D>>
|
||||
del: <T>(url: string) => Promise<AxiosResponse<T, never>>
|
||||
axiosInstance: AxiosInstance
|
||||
}
|
||||
|
||||
const debugRequest = async <T, D = never>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
url: string,
|
||||
doRequest: Promise<AxiosResponse<T, D>>,
|
||||
data?: D
|
||||
): Promise<AxiosResponse<T, D>> => {
|
||||
console.debug(`${method} ${url}`, ...data ? [data] : [])
|
||||
const response = await doRequest
|
||||
console.debug('Response', response)
|
||||
return response
|
||||
}
|
||||
|
||||
export const useAxios = (defaultConfig: CreateAxiosDefaults): AxiosAPI => {
|
||||
const axiosInstance = useMemo(() => axios.create(defaultConfig), [defaultConfig])
|
||||
|
||||
return useMemo((): AxiosAPI => ({
|
||||
get: <T>(url: string): Promise<AxiosResponse<T, never>> =>
|
||||
debugRequest('GET', url, axiosInstance.get<T, AxiosResponse<T, never>, never>(url)),
|
||||
|
||||
post: <T, D>(url: string, data?: D) =>
|
||||
debugRequest('POST', url, axiosInstance.post<T, AxiosResponse<T, D>, D>(url, data), data),
|
||||
|
||||
del: <T>(url: string) =>
|
||||
debugRequest('DELETE', url, axiosInstance.delete<T, AxiosResponse<T, never>, never>(url)),
|
||||
|
||||
axiosInstance
|
||||
}), [axiosInstance])
|
||||
}
|
||||
87
plugins/code-snippets/js/hooks/useFileUploadAPI.ts
Normal file
87
plugins/code-snippets/js/hooks/useFileUploadAPI.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAxios } from './useAxios'
|
||||
import type { AxiosResponse, CreateAxiosDefaults } from 'axios'
|
||||
|
||||
export interface FileUploadRequest {
|
||||
files: FileList
|
||||
}
|
||||
|
||||
export interface FileParseResponse {
|
||||
snippets: ImportableSnippet[]
|
||||
total_count: number
|
||||
message: string
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export interface ImportableSnippet {
|
||||
id?: number
|
||||
name: string
|
||||
desc?: string
|
||||
description?: string
|
||||
code: string
|
||||
tags?: string[]
|
||||
scope?: string
|
||||
source_file?: string
|
||||
table_data: {
|
||||
id: number | string
|
||||
title: string
|
||||
scope: string
|
||||
tags: string
|
||||
description: string
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SnippetImportRequest {
|
||||
snippets: ImportableSnippet[]
|
||||
duplicate_action: 'ignore' | 'replace' | 'skip'
|
||||
network?: boolean
|
||||
}
|
||||
|
||||
export interface SnippetImportResponse {
|
||||
imported: number
|
||||
imported_ids: number[]
|
||||
message: string
|
||||
}
|
||||
|
||||
const ROUTE_BASE = `${window.CODE_SNIPPETS?.restAPI.base}code-snippets/v1/`
|
||||
|
||||
const AXIOS_CONFIG: CreateAxiosDefaults = {
|
||||
headers: { 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce }
|
||||
}
|
||||
|
||||
export interface FileUploadAPI {
|
||||
parseFiles: (request: FileUploadRequest) => Promise<AxiosResponse<FileParseResponse>>
|
||||
importSnippets: (request: SnippetImportRequest) => Promise<AxiosResponse<SnippetImportResponse>>
|
||||
}
|
||||
|
||||
export const useFileUploadAPI = (): FileUploadAPI => {
|
||||
const { axiosInstance } = useAxios(AXIOS_CONFIG)
|
||||
|
||||
return useMemo((): FileUploadAPI => ({
|
||||
parseFiles: (request: FileUploadRequest) => {
|
||||
const formData = new FormData()
|
||||
|
||||
for (let i = 0; i < request.files.length; i++) {
|
||||
formData.append('files[]', request.files[i])
|
||||
}
|
||||
|
||||
return axiosInstance.post<FileParseResponse>(
|
||||
`${ROUTE_BASE}file-upload/parse`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
importSnippets: (request: SnippetImportRequest) => {
|
||||
return axiosInstance.post<SnippetImportResponse>(
|
||||
`${ROUTE_BASE}file-upload/import`,
|
||||
request
|
||||
)
|
||||
}
|
||||
}), [axiosInstance])
|
||||
}
|
||||
52
plugins/code-snippets/js/hooks/useImportersAPI.ts
Normal file
52
plugins/code-snippets/js/hooks/useImportersAPI.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAxios } from './useAxios'
|
||||
import type { AxiosResponse, CreateAxiosDefaults } from 'axios'
|
||||
|
||||
export interface Importer {
|
||||
name: string
|
||||
title: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ImportableSnippet {
|
||||
id: number
|
||||
title: string
|
||||
table_data: {
|
||||
id: number
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImportRequest {
|
||||
ids: number[]
|
||||
network?: boolean
|
||||
auto_add_tags?: boolean
|
||||
tag_value?: string
|
||||
}
|
||||
|
||||
export interface ImportResponse {
|
||||
imported: number[]
|
||||
}
|
||||
|
||||
const ROUTE_BASE = `${window.CODE_SNIPPETS?.restAPI.base}code-snippets/v1/`
|
||||
|
||||
const AXIOS_CONFIG: CreateAxiosDefaults = {
|
||||
headers: { 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce }
|
||||
}
|
||||
|
||||
export interface ImportersAPI {
|
||||
fetchAll: () => Promise<AxiosResponse<Importer[]>>
|
||||
fetchSnippets: (importerName: string) => Promise<AxiosResponse<ImportableSnippet[]>>
|
||||
importSnippets: (importerName: string, request: ImportRequest) => Promise<AxiosResponse<ImportResponse>>
|
||||
}
|
||||
|
||||
export const useImportersAPI = (): ImportersAPI => {
|
||||
const { get, post } = useAxios(AXIOS_CONFIG)
|
||||
|
||||
return useMemo((): ImportersAPI => ({
|
||||
fetchAll: () => get<Importer[]>(`${ROUTE_BASE}importers`),
|
||||
fetchSnippets: (importerName: string) => get<ImportableSnippet[]>(`${ROUTE_BASE}${importerName}`),
|
||||
importSnippets: (importerName: string, request: ImportRequest) =>
|
||||
post<ImportResponse, ImportRequest>(`${ROUTE_BASE}${importerName}/import`, request)
|
||||
}), [get, post])
|
||||
}
|
||||
60
plugins/code-snippets/js/hooks/useRestAPI.tsx
Normal file
60
plugins/code-snippets/js/hooks/useRestAPI.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import axios from 'axios'
|
||||
import { createContextHook } from '../utils/hooks'
|
||||
import { REST_API_AXIOS_CONFIG } from '../utils/restAPI'
|
||||
import { buildSnippetsAPI } from '../utils/snippets/api'
|
||||
import type { SnippetsAPI } from '../utils/snippets/api'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { AxiosInstance, AxiosResponse } from 'axios'
|
||||
|
||||
export interface RestAPIContext {
|
||||
api: RestAPI
|
||||
snippetsAPI: SnippetsAPI
|
||||
axiosInstance: AxiosInstance
|
||||
}
|
||||
|
||||
export interface RestAPI {
|
||||
get: <T>(url: string) => Promise<T>
|
||||
post: <T>(url: string, data?: object) => Promise<T>
|
||||
put: <T>(url: string, data?: object) => Promise<T>
|
||||
del: <T>(url: string) => Promise<T>
|
||||
}
|
||||
|
||||
const debugRequest = async <T, D = never>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||
url: string,
|
||||
doRequest: Promise<AxiosResponse<T, D>>,
|
||||
data?: D
|
||||
): Promise<T> => {
|
||||
console.debug(`${method} ${url}`, ...data ? [data] : [])
|
||||
const response = await doRequest
|
||||
console.debug('Response', response)
|
||||
return response.data
|
||||
}
|
||||
|
||||
const buildRestAPI = (axiosInstance: AxiosInstance): RestAPI => ({
|
||||
get: <T, >(url: string): Promise<T> =>
|
||||
debugRequest('GET', url, axiosInstance.get<T, AxiosResponse<T, never>, never>(url)),
|
||||
|
||||
post: <T, >(url: string, data?: object): Promise<T> =>
|
||||
debugRequest('POST', url, axiosInstance.post<T, AxiosResponse<T, typeof data>, typeof data>(url, data), data),
|
||||
|
||||
del: <T, >(url: string): Promise<T> =>
|
||||
debugRequest('DELETE', url, axiosInstance.delete<T, AxiosResponse<T, never>, never>(url)),
|
||||
|
||||
put: <T, >(url: string, data?: object): Promise<T> =>
|
||||
debugRequest('PUT', url, axiosInstance.put<T, AxiosResponse<T, typeof data>, typeof data>(url, data), data)
|
||||
})
|
||||
|
||||
export const [RestAPIContext, useRestAPI] = createContextHook<RestAPIContext>('RestAPI')
|
||||
|
||||
export const WithRestAPIContext: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const axiosInstance = useMemo(() => axios.create(REST_API_AXIOS_CONFIG), [])
|
||||
|
||||
const api = useMemo(() => buildRestAPI(axiosInstance), [axiosInstance])
|
||||
const snippetsAPI = useMemo(() => buildSnippetsAPI(api), [api])
|
||||
|
||||
const value: RestAPIContext = { api, snippetsAPI, axiosInstance }
|
||||
|
||||
return <RestAPIContext.Provider value={value}>{children}</RestAPIContext.Provider>
|
||||
}
|
||||
69
plugins/code-snippets/js/hooks/useSnippetForm.tsx
Normal file
69
plugins/code-snippets/js/hooks/useSnippetForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { isAxiosError } from 'axios'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { createContextHook } from '../utils/hooks'
|
||||
import { isLicensed } from '../utils/screen'
|
||||
import { isProSnippet } from '../utils/snippets/snippets'
|
||||
import type { Dispatch, PropsWithChildren, SetStateAction } from 'react'
|
||||
import type { ScreenNotice } from '../types/ScreenNotice'
|
||||
import type { Snippet } from '../types/Snippet'
|
||||
import type { CodeEditorInstance } from '../types/WordPressCodeEditor'
|
||||
|
||||
export interface SnippetFormContext {
|
||||
snippet: Snippet
|
||||
isWorking: boolean
|
||||
isReadOnly: boolean
|
||||
setSnippet: Dispatch<SetStateAction<Snippet>>
|
||||
updateSnippet: Dispatch<SetStateAction<Snippet>>
|
||||
setIsWorking: Dispatch<SetStateAction<boolean>>
|
||||
currentNotice: ScreenNotice | undefined
|
||||
setCurrentNotice: Dispatch<SetStateAction<ScreenNotice | undefined>>
|
||||
codeEditorInstance: CodeEditorInstance | undefined
|
||||
handleRequestError: (error: unknown, message?: string) => void
|
||||
setCodeEditorInstance: Dispatch<SetStateAction<CodeEditorInstance | undefined>>
|
||||
}
|
||||
|
||||
export const [SnippetFormContext, useSnippetForm] = createContextHook<SnippetFormContext>('SnippetForm')
|
||||
|
||||
export interface WithSnippetFormContextProps extends PropsWithChildren {
|
||||
initialSnippet: () => Snippet
|
||||
}
|
||||
|
||||
export const WithSnippetFormContext: React.FC<WithSnippetFormContextProps> = ({ children, initialSnippet }) => {
|
||||
const [snippet, setSnippet] = useState<Snippet>(initialSnippet)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [currentNotice, setCurrentNotice] = useState<ScreenNotice>()
|
||||
const [codeEditorInstance, setCodeEditorInstance] = useState<CodeEditorInstance>()
|
||||
|
||||
const isReadOnly = useMemo(() => !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.scope])
|
||||
|
||||
const handleRequestError = useCallback((error: unknown, message?: string) => {
|
||||
console.error('Request failed', error)
|
||||
setIsWorking(false)
|
||||
setCurrentNotice(['error', [message, isAxiosError(error) ? error.message : ''].filter(Boolean).join(' ')])
|
||||
}, [setIsWorking, setCurrentNotice])
|
||||
|
||||
const updateSnippet: Dispatch<SetStateAction<Snippet>> = useCallback((value: SetStateAction<Snippet>) => {
|
||||
setSnippet(previous => {
|
||||
const updated = 'object' === typeof value ? value : value(previous)
|
||||
codeEditorInstance?.codemirror.setValue(updated.code)
|
||||
window.tinymce?.activeEditor.setContent(updated.desc)
|
||||
return updated
|
||||
})
|
||||
}, [codeEditorInstance?.codemirror])
|
||||
|
||||
const value: SnippetFormContext = {
|
||||
snippet,
|
||||
isWorking,
|
||||
isReadOnly,
|
||||
setSnippet,
|
||||
setIsWorking,
|
||||
updateSnippet,
|
||||
currentNotice,
|
||||
setCurrentNotice,
|
||||
codeEditorInstance,
|
||||
handleRequestError,
|
||||
setCodeEditorInstance
|
||||
}
|
||||
|
||||
return <SnippetFormContext.Provider value={value}>{children}</SnippetFormContext.Provider>
|
||||
}
|
||||
42
plugins/code-snippets/js/hooks/useSnippetsList.tsx
Normal file
42
plugins/code-snippets/js/hooks/useSnippetsList.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { createContextHook } from '../utils/hooks'
|
||||
import { isNetworkAdmin } from '../utils/screen'
|
||||
import { useRestAPI } from './useRestAPI'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { Snippet } from '../types/Snippet'
|
||||
|
||||
export interface SnippetsListContext {
|
||||
snippetsList: readonly Snippet[] | undefined
|
||||
refreshSnippetsList: () => Promise<void>
|
||||
}
|
||||
|
||||
const [SnippetsListContext, useSnippetsList] = createContextHook<SnippetsListContext>('SnippetsList')
|
||||
|
||||
export const WithSnippetsListContext: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const { snippetsAPI: { fetchAll } } = useRestAPI()
|
||||
const [snippetsList, setSnippetsList] = useState<Snippet[]>()
|
||||
|
||||
const refreshSnippetsList = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
console.info('Fetching snippets list')
|
||||
const response = await fetchAll(isNetworkAdmin())
|
||||
setSnippetsList(response)
|
||||
} catch (error: unknown) {
|
||||
console.error('Error fetching snippets list', error)
|
||||
}
|
||||
}, [fetchAll])
|
||||
|
||||
useEffect(() => {
|
||||
refreshSnippetsList()
|
||||
.catch(() => undefined)
|
||||
}, [refreshSnippetsList])
|
||||
|
||||
const value: SnippetsListContext = {
|
||||
snippetsList,
|
||||
refreshSnippetsList
|
||||
}
|
||||
|
||||
return <SnippetsListContext.Provider value={value}>{children}</SnippetsListContext.Provider>
|
||||
}
|
||||
|
||||
export { useSnippetsList }
|
||||
125
plugins/code-snippets/js/hooks/useSubmitSnippet.ts
Normal file
125
plugins/code-snippets/js/hooks/useSubmitSnippet.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { addQueryArgs } from '@wordpress/url'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useCallback } from 'react'
|
||||
import { createSnippetObject, isCondition } from '../utils/snippets/snippets'
|
||||
import { useRestAPI } from './useRestAPI'
|
||||
import { useSnippetForm } from './useSnippetForm'
|
||||
import type { Snippet } from '../types/Snippet'
|
||||
|
||||
const snippetMessages = <const> {
|
||||
addNew: __('Add New Snippet', 'code-snippets'),
|
||||
edit: __('Edit Snippet', 'code-snippets'),
|
||||
created: __('Snippet <strong>created</strong>.', 'code-snippets'),
|
||||
updated: __('Snippet <strong>updated</strong>.', 'code-snippets'),
|
||||
createdActivated: __('Snippet <strong>created</strong> and <strong>activated</strong>.', 'code-snippets'),
|
||||
updatedActivated: __('Snippet <strong>updated</strong> and <strong>activated</strong>.', 'code-snippets'),
|
||||
updatedDeactivated: __('Snippet <strong>updated</strong> and <strong>deactivated</strong>'),
|
||||
updatedExecuted: __('Snippet <strong>updated</strong> and <strong>executed</strong>.', 'code-snippets'),
|
||||
failedCreate: __('Could not create snippet.', 'code-snippets'),
|
||||
failedUpdate: __('Could not update snippet.', 'code-snippets')
|
||||
}
|
||||
|
||||
const conditionCreated = __('Condition <strong>created</strong>.', 'code-snippets')
|
||||
const conditionUpdated = __('Condition <strong>updated</strong>.', 'code-snippets')
|
||||
|
||||
const conditionMessages: typeof snippetMessages = {
|
||||
addNew: __('Add New Condition', 'code-snippets'),
|
||||
edit: __('Edit Condition', 'code-snippets'),
|
||||
created: conditionCreated,
|
||||
updated: conditionUpdated,
|
||||
createdActivated: conditionCreated,
|
||||
updatedActivated: conditionUpdated,
|
||||
updatedDeactivated: conditionUpdated,
|
||||
updatedExecuted: conditionUpdated,
|
||||
failedCreate: __('Could not create condition.', 'code-snippets'),
|
||||
failedUpdate: __('Could not update condition.', 'code-snippets')
|
||||
}
|
||||
|
||||
export enum SubmitSnippetAction {
|
||||
SAVE = 'save_snippet',
|
||||
SAVE_AND_ACTIVATE = 'save_snippet_activate',
|
||||
SAVE_AND_EXECUTE = 'save_snippet_execute',
|
||||
SAVE_AND_DEACTIVATE = 'save_snippet_deactivate'
|
||||
}
|
||||
|
||||
const getSuccessNotice = (request: Snippet, response: Snippet, action: SubmitSnippetAction): string => {
|
||||
const messages = 'condition' === request.scope ? conditionMessages : snippetMessages
|
||||
const wasCreated = 0 === request.id
|
||||
|
||||
switch (action) {
|
||||
case SubmitSnippetAction.SAVE:
|
||||
return wasCreated ? messages.created : messages.updated
|
||||
|
||||
case SubmitSnippetAction.SAVE_AND_EXECUTE:
|
||||
return messages.updatedExecuted
|
||||
|
||||
case SubmitSnippetAction.SAVE_AND_ACTIVATE:
|
||||
if ('single-use' === response.scope) {
|
||||
return messages.updatedExecuted
|
||||
} else {
|
||||
return wasCreated
|
||||
? messages.createdActivated
|
||||
: messages.updatedActivated
|
||||
}
|
||||
|
||||
case SubmitSnippetAction.SAVE_AND_DEACTIVATE:
|
||||
return messages.updatedDeactivated
|
||||
}
|
||||
}
|
||||
|
||||
const SUBMIT_ACTION_DELTA: Record<SubmitSnippetAction, Partial<Snippet>> = {
|
||||
[SubmitSnippetAction.SAVE]: {},
|
||||
[SubmitSnippetAction.SAVE_AND_ACTIVATE]: { active: true },
|
||||
[SubmitSnippetAction.SAVE_AND_DEACTIVATE]: { active: false },
|
||||
[SubmitSnippetAction.SAVE_AND_EXECUTE]: { active: true }
|
||||
}
|
||||
|
||||
export interface UseSubmitSnippet {
|
||||
submitSnippet: (action?: SubmitSnippetAction) => Promise<Snippet | undefined>
|
||||
}
|
||||
|
||||
export const useSubmitSnippet = (): UseSubmitSnippet => {
|
||||
const { snippetsAPI } = useRestAPI()
|
||||
const { setIsWorking, setCurrentNotice, snippet, setSnippet } = useSnippetForm()
|
||||
|
||||
const submitSnippet = useCallback(async (action: SubmitSnippetAction = SubmitSnippetAction.SAVE) => {
|
||||
setCurrentNotice(undefined)
|
||||
|
||||
const result = await (async (): Promise<Snippet | string | undefined> => {
|
||||
try {
|
||||
const request: Snippet = { ...snippet, ...SUBMIT_ACTION_DELTA[action] }
|
||||
const response = await (0 === request.id ? snippetsAPI.create(request) : snippetsAPI.update(request))
|
||||
setIsWorking(false)
|
||||
return response.id ? response : undefined
|
||||
} catch (error) {
|
||||
setIsWorking(false)
|
||||
return isAxiosError(error) ? error.message : undefined
|
||||
}
|
||||
})()
|
||||
|
||||
const messages = isCondition(snippet) ? conditionMessages : snippetMessages
|
||||
|
||||
if (undefined === result || 'string' === typeof result) {
|
||||
const message = [
|
||||
snippet.id ? messages.failedUpdate : messages.failedCreate,
|
||||
result ?? __('The server did not send a valid response.', 'code-snippets')
|
||||
]
|
||||
|
||||
setCurrentNotice(['error', message.filter(Boolean).join(' ')])
|
||||
return undefined
|
||||
} else {
|
||||
setSnippet(createSnippetObject(result))
|
||||
setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)])
|
||||
|
||||
if (snippet.id && result.id) {
|
||||
window.document.title = window.document.title.replace(snippetMessages.addNew, messages.edit)
|
||||
window.history.replaceState({}, '', addQueryArgs(window.CODE_SNIPPETS?.urls.edit, { id: result.id }))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}, [snippetsAPI, setIsWorking, setCurrentNotice, snippet, setSnippet])
|
||||
|
||||
return { submitSnippet }
|
||||
}
|
||||
12
plugins/code-snippets/js/import.tsx
Normal file
12
plugins/code-snippets/js/import.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ImportApp } from './components/Import/ImportApp'
|
||||
|
||||
const importContainer = document.getElementById('import-container')
|
||||
|
||||
if (importContainer) {
|
||||
const root = createRoot(importContainer)
|
||||
root.render(<ImportApp />)
|
||||
} else {
|
||||
console.error('Could not find import container.')
|
||||
}
|
||||
5
plugins/code-snippets/js/manage.ts
Normal file
5
plugins/code-snippets/js/manage.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { handleShowCloudPreview, handleSnippetActivationSwitches, handleSnippetPriorityChanges } from './services/manage'
|
||||
|
||||
handleSnippetActivationSwitches()
|
||||
handleSnippetPriorityChanges()
|
||||
handleShowCloudPreview()
|
||||
132
plugins/code-snippets/js/mce.ts
Normal file
132
plugins/code-snippets/js/mce.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import tinymce from 'tinymce'
|
||||
import type { Editor } from 'tinymce'
|
||||
import type { ContentShortcodeAtts, SourceShortcodeAtts } from './types/Shortcodes'
|
||||
import type { LocalisedEditor } from './types/WordPressEditor'
|
||||
|
||||
const convertToValues = (array: Record<string, string>) =>
|
||||
Object.keys(array).map(key => ({
|
||||
text: array[Number(key)],
|
||||
value: key
|
||||
}))
|
||||
|
||||
export const insertContentMenu = (editor: Editor, activeEditor: LocalisedEditor) => ({
|
||||
text: activeEditor.getLang('code_snippets.insert_source_menu'),
|
||||
onclick: () => {
|
||||
editor.windowManager.open({
|
||||
title: activeEditor.getLang('code_snippets.insert_source_title'),
|
||||
body: [
|
||||
{
|
||||
type: 'listbox',
|
||||
name: 'id',
|
||||
label: activeEditor.getLang('code_snippets.snippet_label'),
|
||||
values: convertToValues(<Record<string, string>> activeEditor.getLang('code_snippets.all_snippets'))
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'line_numbers',
|
||||
label: activeEditor.getLang('code_snippets.show_line_numbers_label')
|
||||
}
|
||||
],
|
||||
onsubmit: (event: { data: SourceShortcodeAtts }) => {
|
||||
const id = parseInt(event.data.id, 10)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
let atts = ''
|
||||
|
||||
if (event.data.line_numbers) {
|
||||
atts += ' line_numbers=true'
|
||||
}
|
||||
|
||||
editor.insertContent(`[code_snippet_source id=${id}${atts}]`)
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
})
|
||||
|
||||
export const insertSourceMenu = (editor: Editor, ed: LocalisedEditor) => ({
|
||||
text: ed.getLang('code_snippets.insert_content_menu'),
|
||||
onclick: () => {
|
||||
editor.windowManager.open({
|
||||
title: ed.getLang('code_snippets.insert_content_title'),
|
||||
body: [
|
||||
{
|
||||
type: 'listbox',
|
||||
name: 'id',
|
||||
label: ed.getLang('code_snippets.snippet_label'),
|
||||
values: convertToValues(<Record<string, string>> ed.getLang('code_snippets.content_snippets'))
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'php',
|
||||
label: ed.getLang('code_snippets.php_att_label')
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'format',
|
||||
label: ed.getLang('code_snippets.format_att_label')
|
||||
},
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'shortcodes',
|
||||
label: ed.getLang('code_snippets.shortcodes_att_label')
|
||||
}
|
||||
],
|
||||
onsubmit: (event: { data: ContentShortcodeAtts }) => {
|
||||
const id = parseInt(event.data.id, 10)
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
|
||||
let atts = ''
|
||||
|
||||
for (const [opt, val] of Object.entries(event.data)) {
|
||||
if ('id' !== opt && val) {
|
||||
atts += ` ${opt}=${val}`
|
||||
}
|
||||
}
|
||||
|
||||
editor.insertContent(`[code_snippet id=${id}${atts}]`)
|
||||
}
|
||||
}, {})
|
||||
}
|
||||
})
|
||||
|
||||
// Custom scissors icon as base64-encoded SVG (same as used in WP admin menu)
|
||||
// Base64-encoded version of menu-icon.svg
|
||||
const scissorsIcon =
|
||||
'data:image/svg+xml;base64,' +
|
||||
'PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZX' +
|
||||
'dCb3g9IjAgMCAyNiAyNSI+PHBhdGggZmlsbD0iIzUxNTc1ZiIgZD0iTTYuMTI3IDExLjk2Nmgz' +
|
||||
'LjQxNGEuOTEuOTEgMCAwIDEgLjg0NC41NjMuOTMuOTMgMCAwIDEtLjE4NSAxLjAwNGwuMDA1Lj' +
|
||||
'AxLTIuMzM4IDIuMzU1YTQuMTkgNC4xOSAwIDAgMCAwIDUuODg1QTQuMTEgNC4xMSAwIDAgMCAx' +
|
||||
'MC43ODQgMjNhNC4xMSA0LjExIDAgMCAwIDIuOTE3LTEuMjE3IDQuMiA0LjIgMCAwIDAgMS4wND' +
|
||||
'gtMS44MDIgNC4yIDQuMiAwIDAgMC0uOTE1LTMuOTQgNC4xIDQuMSAwIDAgMC0xLjczMi0xLjE0' +
|
||||
'NWwuNjE0LS42MTloNy44NDZjMS44NyAwIDMuMzkxLTEuNjA0IDMuNDM2LTMuNjA2IDAtLjAzMy' +
|
||||
'4wMDQtLjA2IDAtLjA5MmExLjAyIDEuMDIgMCAwIDAtLjMyNi0uNjYgMSAxIDAgMCAwLS42ODEt' +
|
||||
'LjI2NmgtNS42OTJsNC4xMS00LjE0NWExLjAyNSAxLjAyNSAwIDAgMCAuMDY4LTEuMzc0Yy0uMD' +
|
||||
'IyLS4wMjctLjA0NC0uMDQ2LS4wNjgtLjA2OC0xLjQzLTEuMzc4LTMuNjM0LTEuNDMtNC45NTMt' +
|
||||
'LjA5OGwtNS42MzUgNS42ODVIOS44MmMuMzk4LS44MS41MjQtMS43My4zNTgtMi42MTlhNC4xNy' +
|
||||
'A0LjE3IDAgMCAwLTEuMjc5LTIuMzA4IDQuMDk2IDQuMDk2IDAgMCAwLTQuOTUtLjQ1NyA0LjE1' +
|
||||
'IDQuMTUgMCAwIDAtMS42NzIgMi4wMzYgNC4yIDQuMiAwIDAgMC0uMTE5IDIuNjQyYy4yNDYuOD' +
|
||||
'cuNzY3IDEuNjM1IDEuNDgzIDIuMThzMS41OS44NCAyLjQ4Ni44MzlNNy45NiA3LjgwNWMwIC40' +
|
||||
'OS0uMTkzLjk2LS41MzYgMS4zMDhhMS44MjUgMS44MjUgMCAwIDEtMi41OTIuMDAxQTEuODU3ID' +
|
||||
'EuODU3IDAgMCAxIDQuODMgNi41YTEuODI1IDEuODI1IDAgMCAxIDIuNTkzIDBjLjM0My4zNDYu' +
|
||||
'NTM3LjgxNi41MzcgMS4zMDZtNC4xMTkgOS43MzNhMS44NiAxLjg2IDAgMCAxLS41OTUgMy4wMT' +
|
||||
'QgMS44MSAxLjgxIDAgMCAxLTEuOTk5LS40MDIgMS44NSAxLjg1IDAgMCAxLS41MzYtMS4zMDYg' +
|
||||
'MS44NiAxLjg2IDAgMCAxIDEuMTMtMS43MSAxLjgxIDEuODEgMCAwIDEgMiAuNDA0Ii8+PC9zdmc+'
|
||||
|
||||
tinymce.PluginManager.add('code_snippets', editor => {
|
||||
const activeEditor = <LocalisedEditor> tinymce.activeEditor
|
||||
|
||||
editor.addButton('code_snippets', {
|
||||
type: 'menubutton',
|
||||
title: 'Code Snippets',
|
||||
image: scissorsIcon,
|
||||
menu: [
|
||||
insertContentMenu(editor, activeEditor),
|
||||
insertSourceMenu(editor, activeEditor)
|
||||
],
|
||||
})
|
||||
})
|
||||
23
plugins/code-snippets/js/prism.ts
Normal file
23
plugins/code-snippets/js/prism.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import Prism from 'prismjs'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/components/prism-markup-templating'
|
||||
import 'prismjs/components/prism-clike'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/plugins/line-highlight/prism-line-highlight'
|
||||
import 'prismjs/plugins/line-numbers/prism-line-numbers'
|
||||
import 'prismjs/plugins/toolbar/prism-toolbar'
|
||||
import 'prismjs/plugins/show-language/prism-show-language'
|
||||
import 'prismjs/plugins/copy-to-clipboard/prism-copy-to-clipboard'
|
||||
import 'prismjs/plugins/inline-color/prism-inline-color'
|
||||
import 'prismjs/plugins/previewers/prism-previewers'
|
||||
import 'prismjs/plugins/autolinker/prism-autolinker'
|
||||
|
||||
document.addEventListener('readystatechange', () => {
|
||||
if ('complete' === document.readyState) {
|
||||
Prism.highlightAll()
|
||||
}
|
||||
})
|
||||
|
||||
window.CODE_SNIPPETS_PRISM = Prism
|
||||
79
plugins/code-snippets/js/services/manage/activation.ts
Normal file
79
plugins/code-snippets/js/services/manage/activation.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { __ } from '@wordpress/i18n'
|
||||
import { updateSnippet } from './requests'
|
||||
import type { Snippet } from '../../types/Snippet'
|
||||
|
||||
/**
|
||||
* Update the snippet count of a specific view
|
||||
* @param element
|
||||
* @param increment
|
||||
*/
|
||||
const updateViewCount = (element: HTMLElement | null, increment: boolean) => {
|
||||
if (element?.textContent) {
|
||||
let count = parseInt(element.textContent.replace(/\((?<count>\d+)\)/, '$1'), 10)
|
||||
count += increment ? 1 : -1
|
||||
element.textContent = `(${count})`
|
||||
} else {
|
||||
console.error('Could not update view count.', element)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate an inactive snippet, or deactivate an active snippet
|
||||
* @param link
|
||||
* @param event
|
||||
*/
|
||||
export const toggleSnippetActive = (link: HTMLAnchorElement, event: Event) => {
|
||||
const row = link.parentElement?.parentElement // Switch < cell < row
|
||||
if (!row) {
|
||||
console.error('Could not toggle snippet active status.', row)
|
||||
return
|
||||
}
|
||||
|
||||
const match = /\b(?:in)?active-snippet\b/.exec(row.className)
|
||||
if (!match) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const activating = 'inactive-snippet' === match[0]
|
||||
const snippet: Partial<Snippet> = { active: activating }
|
||||
|
||||
updateSnippet('active', row, snippet, response => {
|
||||
const button: HTMLAnchorElement | null = row.querySelector('.snippet-activation-switch')
|
||||
|
||||
if (response.success) {
|
||||
row.className = activating
|
||||
? row.className.replace(/\binactive-snippet\b/, 'active-snippet')
|
||||
: row.className.replace(/\bactive-snippet\b/, 'inactive-snippet')
|
||||
|
||||
const views = document.querySelector('.subsubsub')
|
||||
const activeCount = views?.querySelector<HTMLElement>('.active .count')
|
||||
const inactiveCount = views?.querySelector<HTMLElement>('.inactive .count')
|
||||
|
||||
if (activeCount) {
|
||||
updateViewCount(activeCount, activating)
|
||||
}
|
||||
|
||||
if (inactiveCount) {
|
||||
updateViewCount(inactiveCount, activating)
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.title = activating ? __('Deactivate', 'code-snippets') : __('Activate', 'code-snippets')
|
||||
}
|
||||
} else {
|
||||
row.className += ' erroneous-snippet'
|
||||
|
||||
if (button) {
|
||||
button.title = __('An error occurred when attempting to activate', 'code-snippets')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const handleSnippetActivationSwitches = () => {
|
||||
for (const link of document.getElementsByClassName('snippet-activation-switch')) {
|
||||
link.addEventListener('click', event => toggleSnippetActive(<HTMLAnchorElement> link, event))
|
||||
}
|
||||
}
|
||||
45
plugins/code-snippets/js/services/manage/cloud.ts
Normal file
45
plugins/code-snippets/js/services/manage/cloud.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import Prism from 'prismjs'
|
||||
import 'prismjs/components/prism-clike'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/components/prism-css'
|
||||
import 'prismjs/components/prism-php'
|
||||
import 'prismjs/components/prism-markup'
|
||||
import 'prismjs/plugins/keep-markup/prism-keep-markup'
|
||||
|
||||
/**
|
||||
* Handle clicks on snippet preview button.
|
||||
*/
|
||||
export const handleShowCloudPreview = () => {
|
||||
const previewButtons = document.querySelectorAll('.cloud-snippet-preview')
|
||||
|
||||
previewButtons.forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const snippetId = button.getAttribute('data-snippet')
|
||||
const snippetLanguage = button.getAttribute('data-lang')
|
||||
|
||||
const snippetCodeInput = <HTMLInputElement | null> document.getElementById(`cloud-snippet-code-${snippetId}`)
|
||||
const snippetCodeModalTag = document.getElementById('snippet-code-thickbox')
|
||||
|
||||
if (!snippetCodeModalTag || !snippetCodeInput) {
|
||||
return
|
||||
}
|
||||
|
||||
snippetCodeModalTag.classList.remove(...snippetCodeModalTag.classList)
|
||||
snippetCodeModalTag.classList.add(`language-${snippetLanguage}`)
|
||||
snippetCodeModalTag.textContent = snippetCodeInput.value
|
||||
|
||||
if ('markup' === snippetLanguage) {
|
||||
snippetCodeModalTag.innerHTML = `<xmp>${snippetCodeInput.value}</xmp>`
|
||||
}
|
||||
|
||||
if ('php' === snippetLanguage) {
|
||||
// Check if there is an opening php tag if not add it.
|
||||
if (!snippetCodeInput.value.startsWith('<?php')) {
|
||||
snippetCodeModalTag.textContent = `<?php\n${snippetCodeInput.value}`
|
||||
}
|
||||
}
|
||||
|
||||
Prism.highlightElement(snippetCodeModalTag)
|
||||
})
|
||||
})
|
||||
}
|
||||
3
plugins/code-snippets/js/services/manage/index.ts
Normal file
3
plugins/code-snippets/js/services/manage/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { handleSnippetActivationSwitches } from './activation'
|
||||
export { handleSnippetPriorityChanges } from './priority'
|
||||
export { handleShowCloudPreview } from './cloud'
|
||||
22
plugins/code-snippets/js/services/manage/priority.ts
Normal file
22
plugins/code-snippets/js/services/manage/priority.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { updateSnippet } from './requests'
|
||||
import type { Snippet } from '../../types/Snippet'
|
||||
|
||||
/**
|
||||
* Update the priority of a snippet
|
||||
*/
|
||||
export const updateSnippetPriority = (element: HTMLInputElement) => {
|
||||
const row = element.parentElement?.parentElement
|
||||
const snippet: Partial<Snippet> = { priority: parseFloat(element.value) }
|
||||
if (row) {
|
||||
updateSnippet('priority', row, snippet)
|
||||
} else {
|
||||
console.error('Could not update snippet information.', snippet, row)
|
||||
}
|
||||
}
|
||||
|
||||
export const handleSnippetPriorityChanges = () => {
|
||||
for (const field of <HTMLCollectionOf<HTMLInputElement>> document.getElementsByClassName('snippet-priority')) {
|
||||
field.addEventListener('input', () => updateSnippetPriority(field))
|
||||
field.disabled = false
|
||||
}
|
||||
}
|
||||
56
plugins/code-snippets/js/services/manage/requests.ts
Normal file
56
plugins/code-snippets/js/services/manage/requests.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { isNetworkAdmin } from '../../utils/screen'
|
||||
import type { SnippetSchema } from '../../types/schema/SnippetSchema'
|
||||
import type { Snippet, SnippetScope } from '../../types/Snippet'
|
||||
|
||||
export interface ResponseData<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
}
|
||||
|
||||
export type SuccessCallback = (response: ResponseData) => void
|
||||
|
||||
const sendSnippetRequest = (query: string, onSuccess?: SuccessCallback) => {
|
||||
const request = new XMLHttpRequest()
|
||||
request.open('POST', window.ajaxurl, true)
|
||||
request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8')
|
||||
|
||||
request.onload = () => {
|
||||
const success = 200
|
||||
const errorStart = 400
|
||||
if (success > request.status || errorStart <= request.status) {
|
||||
return
|
||||
}
|
||||
|
||||
console.info(request.responseText)
|
||||
onSuccess?.(<ResponseData> JSON.parse(request.responseText))
|
||||
}
|
||||
|
||||
request.send(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the data of a given snippet using AJAX
|
||||
* @param field
|
||||
* @param row
|
||||
* @param snippet
|
||||
* @param successCallback
|
||||
*/
|
||||
export const updateSnippet = (field: keyof Snippet, row: Element, snippet: Partial<Snippet>, successCallback?: SuccessCallback) => {
|
||||
const nonce = <HTMLInputElement | null> document.getElementById('code_snippets_ajax_nonce')
|
||||
const columnId = row.querySelector('.column-id')
|
||||
|
||||
if (!nonce || !columnId?.textContent || !parseInt(columnId.textContent, 10)) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedSnippet: Partial<SnippetSchema> = {
|
||||
id: parseInt(columnId.textContent, 10),
|
||||
shared_network: null !== /\bshared-network-snippet\b/.exec(row.className),
|
||||
network: snippet.shared_network ?? isNetworkAdmin(),
|
||||
scope: <SnippetScope | null> row.getAttribute('data-snippet-scope') ?? snippet.scope,
|
||||
...snippet
|
||||
}
|
||||
|
||||
const queryString = `action=update_code_snippet&_ajax_nonce=${nonce.value}&field=${field}&snippet=${JSON.stringify(updatedSnippet)}`
|
||||
sendSnippetRequest(queryString, successCallback)
|
||||
}
|
||||
55
plugins/code-snippets/js/services/settings/editor-preview.ts
Normal file
55
plugins/code-snippets/js/services/settings/editor-preview.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import '../../editor'
|
||||
|
||||
const parseSelect = (select: HTMLSelectElement) => select.options[select.selectedIndex].value
|
||||
const parseCheckbox = (checkbox: HTMLInputElement) => checkbox.checked
|
||||
const parseNumber = (input: HTMLInputElement) => parseInt(input.value, 10)
|
||||
|
||||
const initialiseCodeMirror = () => {
|
||||
const { codeEditor } = window.wp
|
||||
const textarea = document.getElementById('code_snippets_editor_preview')
|
||||
|
||||
if (textarea) {
|
||||
window.code_snippets_editor_preview = codeEditor.initialize(textarea)
|
||||
return window.code_snippets_editor_preview.codemirror
|
||||
}
|
||||
|
||||
console.error('Could not initialise CodeMirror on textarea.', textarea)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const handleEditorPreviewUpdates = () => {
|
||||
const editor = initialiseCodeMirror()
|
||||
const editorSettings = window.code_snippets_editor_settings
|
||||
|
||||
for (const setting of editorSettings) {
|
||||
const element = document.querySelector(`[name="code_snippets_settings[editor][${setting.name}]"]`)
|
||||
|
||||
element?.addEventListener('change', () => {
|
||||
const opt = setting.codemirror
|
||||
|
||||
const value = (() => {
|
||||
switch (setting.type) {
|
||||
case 'select':
|
||||
return parseSelect(<HTMLSelectElement> element)
|
||||
case 'checkbox':
|
||||
return parseCheckbox(<HTMLInputElement> element)
|
||||
case 'number':
|
||||
return parseNumber(<HTMLInputElement> element)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (null !== value) {
|
||||
if ('font_size' === setting.name) {
|
||||
const codeElement = document.querySelector('.CodeMirror-code')
|
||||
if (codeElement && codeElement instanceof HTMLElement) {
|
||||
codeElement.style.fontSize = `${value}px`
|
||||
}
|
||||
} else {
|
||||
editor?.setOption(opt, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3
plugins/code-snippets/js/services/settings/index.ts
Normal file
3
plugins/code-snippets/js/services/settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { handleSettingsTabs } from './tabs'
|
||||
export { handleEditorPreviewUpdates } from './editor-preview'
|
||||
export { initVersionSwitch } from './version'
|
||||
50
plugins/code-snippets/js/services/settings/tabs.ts
Normal file
50
plugins/code-snippets/js/services/settings/tabs.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
const selectTab = (tabsWrapper: Element, tab: Element, section: string) => {
|
||||
// Swap the active tab class from the previously active tab to the current one.
|
||||
tabsWrapper.querySelector('.nav-tab-active')?.classList.remove('nav-tab-active')
|
||||
tab.classList.add('nav-tab-active')
|
||||
|
||||
// Update the current active tab attribute so that only the active tab is displayed.
|
||||
tabsWrapper.closest('.wrap')?.setAttribute('data-active-tab', section)
|
||||
}
|
||||
|
||||
// Refresh the editor preview if we're viewing the editor section.
|
||||
const refreshEditorPreview = (section: string) => {
|
||||
if ('editor' === section) {
|
||||
window.code_snippets_editor_preview?.codemirror.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Update the http referer value so that any redirections lead back to this tab.
|
||||
const updateHttpReferer = (section: string) => {
|
||||
const httpReferer = document.querySelector<HTMLInputElement>('input[name=_wp_http_referer]')
|
||||
if (!httpReferer) {
|
||||
console.error('could not find http referer')
|
||||
return
|
||||
}
|
||||
|
||||
const newReferer = httpReferer.value.replace(/(?<base>[&?]section=)[^&]+/, `$1${section}`)
|
||||
httpReferer.value = newReferer + (newReferer === httpReferer.value ? `§ion=${section}` : '')
|
||||
}
|
||||
|
||||
export const handleSettingsTabs = () => {
|
||||
const tabsWrapper = document.getElementById('settings-sections-tabs')
|
||||
if (!tabsWrapper) {
|
||||
console.error('Could not find snippets tabs')
|
||||
return
|
||||
}
|
||||
|
||||
const tabs = tabsWrapper.querySelectorAll('.nav-tab')
|
||||
|
||||
for (const tab of tabs) {
|
||||
tab.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
const section = tab.getAttribute('data-section')
|
||||
|
||||
if (section) {
|
||||
selectTab(tabsWrapper, tab, section)
|
||||
refreshEditorPreview(section)
|
||||
updateHttpReferer(section)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
182
plugins/code-snippets/js/services/settings/version.ts
Normal file
182
plugins/code-snippets/js/services/settings/version.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Handles version switching UI on the settings screen.
|
||||
// Exported init function so callers can opt-in like other settings modules.
|
||||
// Uses vanilla DOM APIs and the global `code_snippets_version_switch` config
|
||||
// injected by PHP via wp_add_inline_script.
|
||||
|
||||
interface VersionConfig {
|
||||
ajaxurl?: string
|
||||
nonce_switch?: string
|
||||
nonce_refresh?: string
|
||||
|
||||
}
|
||||
|
||||
interface AjaxResponse {
|
||||
success?: boolean
|
||||
data?: {
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
code_snippets_version_switch?: VersionConfig
|
||||
__code_snippets_i18n?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
const el = (id: string): HTMLElement | null => document.getElementById(id)
|
||||
|
||||
const getConfig = (): VersionConfig => {
|
||||
const w = <{ code_snippets_version_switch?: VersionConfig }><unknown>window
|
||||
return w.code_snippets_version_switch ?? {}
|
||||
}
|
||||
|
||||
const getCurrentVersion = (): string => (document.querySelector('.current-version')?.textContent ?? '').trim()
|
||||
|
||||
const getI18n = (key: string, fallback: string): string => window.__code_snippets_i18n?.[key] ?? fallback
|
||||
|
||||
const bindDropdown = (
|
||||
dropdown: HTMLSelectElement,
|
||||
button: HTMLButtonElement | null,
|
||||
currentVersion: string,
|
||||
): void => {
|
||||
dropdown.addEventListener('change', (): void => {
|
||||
const selectedVersion = dropdown.value
|
||||
if (!button) {
|
||||
return
|
||||
}
|
||||
if (!selectedVersion || selectedVersion === currentVersion) {
|
||||
button.disabled = true
|
||||
const warn = el('version-switch-warning')
|
||||
if (warn) { warn.setAttribute('style', 'display: none;') }
|
||||
} else {
|
||||
button.disabled = false
|
||||
const warn = el('version-switch-warning')
|
||||
if (warn) { warn.setAttribute('style', '') }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const SUCCESS_RELOAD_MS = 3000
|
||||
|
||||
const postForm = async (data: Record<string, string>, cfg: VersionConfig): Promise<AjaxResponse> => {
|
||||
const body = new URLSearchParams()
|
||||
Object.keys(data).forEach(k => body.append(k, data[k]))
|
||||
const resp = await fetch(cfg.ajaxurl ?? '/wp-admin/admin-ajax.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const json = <AjaxResponse> await resp.json()
|
||||
return json
|
||||
}
|
||||
|
||||
const bindSwitch = (
|
||||
button: HTMLButtonElement,
|
||||
dropdown: HTMLSelectElement,
|
||||
result: HTMLDivElement,
|
||||
cfg: VersionConfig,
|
||||
currentVersion: string,
|
||||
): void => {
|
||||
button.addEventListener('click', (): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const targetVersion = dropdown.value
|
||||
if (!targetVersion || targetVersion === currentVersion) {
|
||||
result.className = 'notice notice-warning'
|
||||
result.innerHTML = `<p>${getI18n('selectDifferent', 'Please select a different version to switch to.')}</p>`
|
||||
result.style.display = ''
|
||||
return
|
||||
}
|
||||
|
||||
button.disabled = true
|
||||
const originalText = button.textContent ?? ''
|
||||
button.textContent = getI18n('switching', 'Switching...')
|
||||
|
||||
result.className = 'notice notice-info'
|
||||
result.innerHTML = `<p>${getI18n('processing', 'Processing version switch. Please wait...')}</p>`
|
||||
result.style.display = ''
|
||||
|
||||
try {
|
||||
const response = await postForm({
|
||||
action: 'code_snippets_switch_version',
|
||||
target_version: targetVersion,
|
||||
nonce: cfg.nonce_switch ?? '',
|
||||
}, cfg)
|
||||
|
||||
if (response.success) {
|
||||
result.className = 'notice notice-success'
|
||||
result.innerHTML = `<p>${response.data?.message ?? ''}</p>`
|
||||
setTimeout(() => window.location.reload(), SUCCESS_RELOAD_MS)
|
||||
return
|
||||
}
|
||||
|
||||
result.className = 'notice notice-error'
|
||||
result.innerHTML = `<p>${response.data?.message ?? getI18n('error', 'An error occurred.')}</p>`
|
||||
button.disabled = false
|
||||
button.textContent = originalText
|
||||
} catch (_err) {
|
||||
result.className = 'notice notice-error'
|
||||
result.innerHTML = `<p>${getI18n('errorSwitch', 'An error occurred while switching versions. Please try again.')}</p>`
|
||||
button.disabled = false
|
||||
button.textContent = originalText
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
const REFRESH_RELOAD_MS = 1000
|
||||
|
||||
const bindRefresh = (
|
||||
btn: HTMLButtonElement,
|
||||
cfg: VersionConfig,
|
||||
): void => {
|
||||
btn.addEventListener('click', (): void => {
|
||||
void (async (): Promise<void> => {
|
||||
const original = btn.textContent ?? ''
|
||||
btn.disabled = true
|
||||
btn.textContent = getI18n('refreshing', 'Refreshing...')
|
||||
|
||||
try {
|
||||
await postForm({
|
||||
action: 'code_snippets_refresh_versions',
|
||||
nonce: cfg.nonce_refresh ?? '',
|
||||
}, cfg)
|
||||
|
||||
btn.textContent = getI18n('refreshed', 'Refreshed!')
|
||||
setTimeout(() => {
|
||||
btn.disabled = false
|
||||
btn.textContent = original
|
||||
window.location.reload()
|
||||
}, REFRESH_RELOAD_MS)
|
||||
} catch {
|
||||
btn.disabled = false
|
||||
btn.textContent = original
|
||||
}
|
||||
})()
|
||||
})
|
||||
}
|
||||
|
||||
export const initVersionSwitch = (): void => {
|
||||
const cfg = getConfig()
|
||||
const currentVersion = getCurrentVersion()
|
||||
|
||||
const button = <HTMLButtonElement | null> el('switch-version-btn')
|
||||
const dropdown = <HTMLSelectElement | null> el('target_version')
|
||||
const result = <HTMLDivElement | null> el('version-switch-result')
|
||||
const refreshBtn = <HTMLButtonElement | null> el('refresh-versions-btn')
|
||||
|
||||
if (dropdown) {
|
||||
bindDropdown(dropdown, button, currentVersion)
|
||||
}
|
||||
|
||||
if (button && dropdown && result) {
|
||||
bindSwitch(button, dropdown, result, cfg, currentVersion)
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
bindRefresh(refreshBtn, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
plugins/code-snippets/js/settings.ts
Normal file
5
plugins/code-snippets/js/settings.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { handleEditorPreviewUpdates, handleSettingsTabs, initVersionSwitch } from './services/settings'
|
||||
|
||||
handleSettingsTabs()
|
||||
handleEditorPreviewUpdates()
|
||||
initVersionSwitch()
|
||||
31
plugins/code-snippets/js/types/KeyboardShortcut.ts
Normal file
31
plugins/code-snippets/js/types/KeyboardShortcut.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { _x } from '@wordpress/i18n'
|
||||
|
||||
export const KEYBOARD_KEYS = <const> {
|
||||
'Cmd': _x('Cmd', 'keyboard key', 'code-snippets'),
|
||||
'Ctrl': _x('Ctrl', 'keyboard key', 'code-snippets'),
|
||||
'Shift': _x('Shift', 'keyboard key', 'code-snippets'),
|
||||
'Option': _x('Option', 'keyboard key', 'code-snippets'),
|
||||
'Alt': _x('Alt', 'keyboard key', 'code-snippets'),
|
||||
'Tab': _x('Tab', 'keyboard key', 'code-snippets'),
|
||||
'Up': _x('Up', 'keyboard key', 'code-snippets'),
|
||||
'Down': _x('Down', 'keyboard key', 'code-snippets'),
|
||||
'A': _x('A', 'keyboard key', 'code-snippets'),
|
||||
'D': _x('D', 'keyboard key', 'code-snippets'),
|
||||
'F': _x('F', 'keyboard key', 'code-snippets'),
|
||||
'G': _x('G', 'keyboard key', 'code-snippets'),
|
||||
'R': _x('R', 'keyboard key', 'code-snippets'),
|
||||
'S': _x('S', 'keyboard key', 'code-snippets'),
|
||||
'Y': _x('Y', 'keyboard key', 'code-snippets'),
|
||||
'Z': _x('Z', 'keyboard key', 'code-snippets'),
|
||||
'/': _x('/', 'keyboard key', 'code-snippets'),
|
||||
'[': _x(']', 'keyboard key', 'code-snippets'),
|
||||
']': _x(']', 'keyboard key', 'code-snippets')
|
||||
}
|
||||
|
||||
export type KeyboardKey = keyof typeof KEYBOARD_KEYS
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
label: string
|
||||
mod: KeyboardKey | KeyboardKey[]
|
||||
key: KeyboardKey
|
||||
}
|
||||
1
plugins/code-snippets/js/types/ScreenNotice.ts
Normal file
1
plugins/code-snippets/js/types/ScreenNotice.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ScreenNotice = ['error' | 'updated', string]
|
||||
13
plugins/code-snippets/js/types/SelectOption.ts
Normal file
13
plugins/code-snippets/js/types/SelectOption.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { GroupBase, Options, OptionsOrGroups } from 'react-select'
|
||||
|
||||
export interface SelectOption<T> {
|
||||
readonly key?: string | number
|
||||
readonly value: T
|
||||
readonly label: string
|
||||
}
|
||||
|
||||
export type SelectGroup<T> = GroupBase<SelectOption<T>>
|
||||
|
||||
export type SelectOptions<T> = Options<SelectOption<T>>
|
||||
|
||||
export type SelectGroups<T> = OptionsOrGroups<SelectOption<T>, SelectGroup<T>>
|
||||
12
plugins/code-snippets/js/types/Shortcodes.ts
Normal file
12
plugins/code-snippets/js/types/Shortcodes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface SourceShortcodeAtts {
|
||||
id: string
|
||||
line_numbers: boolean
|
||||
}
|
||||
|
||||
export interface ContentShortcodeAtts {
|
||||
id: string
|
||||
name: string
|
||||
php: boolean
|
||||
format: boolean
|
||||
shortcodes: boolean
|
||||
}
|
||||
29
plugins/code-snippets/js/types/Snippet.ts
Normal file
29
plugins/code-snippets/js/types/Snippet.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface Snippet {
|
||||
readonly id: number
|
||||
readonly name: string
|
||||
readonly desc: string
|
||||
readonly code: string
|
||||
readonly tags: string[]
|
||||
readonly scope: SnippetScope
|
||||
readonly priority: number
|
||||
readonly active: boolean
|
||||
readonly network: boolean
|
||||
readonly shared_network?: boolean | null
|
||||
readonly modified?: string
|
||||
readonly conditionId: number
|
||||
readonly code_error?: readonly [string, number] | null
|
||||
}
|
||||
|
||||
export type SnippetCodeType = 'php' | 'html' | 'css' | 'js'
|
||||
export type SnippetType = SnippetCodeType | 'cond'
|
||||
|
||||
export type SnippetCodeScope = typeof SNIPPET_TYPE_SCOPES[SnippetCodeType][number]
|
||||
export type SnippetScope = typeof SNIPPET_TYPE_SCOPES[SnippetType][number]
|
||||
|
||||
export const SNIPPET_TYPE_SCOPES = <const> {
|
||||
php: ['global', 'admin', 'front-end', 'single-use'],
|
||||
html: ['content', 'head-content', 'footer-content'],
|
||||
css: ['admin-css', 'site-css'],
|
||||
js: ['site-head-js', 'site-footer-js'],
|
||||
cond: ['condition']
|
||||
}
|
||||
59
plugins/code-snippets/js/types/Window.ts
Normal file
59
plugins/code-snippets/js/types/Window.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type Prism from 'prismjs'
|
||||
import type tinymce from 'tinymce'
|
||||
import type { Snippet } from './Snippet'
|
||||
import type { CodeEditorInstance, EditorOption, WordPressCodeEditor } from './WordPressCodeEditor'
|
||||
import type { WordPressEditor } from './WordPressEditor'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
readonly wp: {
|
||||
readonly editor?: WordPressEditor
|
||||
readonly codeEditor: WordPressCodeEditor
|
||||
}
|
||||
readonly pagenow: string
|
||||
readonly ajaxurl: string
|
||||
readonly tinymce?: tinymce.EditorManager
|
||||
readonly wpActiveEditor?: string
|
||||
code_snippets_editor_preview?: CodeEditorInstance
|
||||
readonly code_snippets_editor_settings: EditorOption[]
|
||||
CODE_SNIPPETS_PRISM?: typeof Prism
|
||||
readonly CODE_SNIPPETS?: {
|
||||
isLicensed: boolean
|
||||
restAPI: {
|
||||
base: string
|
||||
snippets: string
|
||||
conditions: string
|
||||
cloud: string
|
||||
nonce: string
|
||||
localToken: string
|
||||
}
|
||||
urls: {
|
||||
plugin: string
|
||||
manage: string
|
||||
addNew: string
|
||||
edit: string
|
||||
connectCloud: string
|
||||
}
|
||||
}
|
||||
readonly CODE_SNIPPETS_EDIT?: {
|
||||
snippet: Snippet
|
||||
pageTitleActions: Record<string, string>
|
||||
isPreview: boolean
|
||||
isLicensed: boolean
|
||||
enableDownloads: boolean
|
||||
activateByDefault: boolean
|
||||
enableDescription: boolean
|
||||
hideUpsell: boolean
|
||||
editorTheme: string
|
||||
tagOptions: {
|
||||
enabled: boolean
|
||||
allowSpaces: boolean
|
||||
availableTags: string[]
|
||||
}
|
||||
descEditorOptions: {
|
||||
rows: number
|
||||
mediaButtons: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
plugins/code-snippets/js/types/WordPressCodeEditor.ts
Normal file
28
plugins/code-snippets/js/types/WordPressCodeEditor.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Editor, EditorConfiguration } from 'codemirror'
|
||||
|
||||
export interface EditorOption {
|
||||
name: string
|
||||
type: 'checkbox' | 'number' | 'select'
|
||||
codemirror: keyof EditorConfiguration
|
||||
}
|
||||
|
||||
export interface CodeEditorInstance {
|
||||
codemirror: Editor
|
||||
settings: CodeEditorSettings
|
||||
}
|
||||
|
||||
export interface CodeEditorSettings {
|
||||
codemirror: EditorConfiguration
|
||||
csslint: Record<string, unknown>
|
||||
htmlhint: Record<string, unknown>
|
||||
jshint: Record<string, unknown>
|
||||
onTabNext: () => void
|
||||
onTabPrevious: () => void
|
||||
onChangeLintingErrors: () => void
|
||||
onUpdateErrorNotice: () => void
|
||||
}
|
||||
|
||||
export interface WordPressCodeEditor {
|
||||
initialize: (textarea: Element, options?: Partial<CodeEditorSettings>) => CodeEditorInstance
|
||||
defaultSettings: CodeEditorSettings
|
||||
}
|
||||
22
plugins/code-snippets/js/types/WordPressEditor.ts
Normal file
22
plugins/code-snippets/js/types/WordPressEditor.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type tinymce from 'tinymce'
|
||||
|
||||
export interface VisualEditorSettings {
|
||||
tinymce: boolean | tinymce.Settings & {
|
||||
toolbar1?: string | string[]
|
||||
toolbar2?: string | string[]
|
||||
toolbar3?: string | string[]
|
||||
toolbar4?: string | string[]
|
||||
}
|
||||
quicktags: boolean | Record<string, string>
|
||||
mediaButtons: boolean
|
||||
}
|
||||
|
||||
export interface WordPressEditor {
|
||||
initialize: (id: string, settings?: Partial<VisualEditorSettings>) => void
|
||||
remove: (id: string) => void
|
||||
getContent: (id: string) => string
|
||||
}
|
||||
|
||||
export interface LocalisedEditor extends tinymce.Editor {
|
||||
getLang: (s: string) => string | Record<string, string>
|
||||
}
|
||||
20
plugins/code-snippets/js/types/schema/SnippetSchema.ts
Normal file
20
plugins/code-snippets/js/types/schema/SnippetSchema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SnippetScope } from '../Snippet'
|
||||
|
||||
export interface WritableSnippetSchema {
|
||||
name?: string
|
||||
desc?: string
|
||||
code?: string
|
||||
tags?: string[]
|
||||
scope?: SnippetScope
|
||||
condition_id?: number
|
||||
active?: boolean
|
||||
priority?: number
|
||||
network?: boolean | null
|
||||
shared_network?: boolean | null
|
||||
}
|
||||
|
||||
export interface SnippetSchema extends Readonly<Required<WritableSnippetSchema>> {
|
||||
readonly id: number
|
||||
readonly modified: string
|
||||
readonly code_error?: readonly [string, number] | null
|
||||
}
|
||||
7
plugins/code-snippets/js/types/schema/SnippetsExport.ts
Normal file
7
plugins/code-snippets/js/types/schema/SnippetsExport.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Snippet } from '../Snippet'
|
||||
|
||||
export interface SnippetsExport {
|
||||
generator: string
|
||||
date_created: string
|
||||
snippets: Snippet[]
|
||||
}
|
||||
170
plugins/code-snippets/js/utils/Linter.ts
Normal file
170
plugins/code-snippets/js/utils/Linter.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Based on work licensed under the BSD 3-Clause license.
|
||||
*
|
||||
* Copyright (c) 2017, glayzzle
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are met:
|
||||
*
|
||||
* * Redistributions of source code must retain the above copyright notice, this
|
||||
* list of conditions and the following disclaimer.
|
||||
*
|
||||
* * Redistributions in binary form must reproduce the above copyright notice,
|
||||
* this list of conditions and the following disclaimer in the documentation
|
||||
* and/or other materials provided with the distribution.
|
||||
*
|
||||
* * Neither the name of the copyright holder nor the names of its
|
||||
* contributors may be used to endorse or promote products derived from
|
||||
* this software without specific prior written permission.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
* SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
* OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
import { Engine } from 'php-parser'
|
||||
import CodeMirror from 'codemirror'
|
||||
import type { Block, Location, Node } from 'php-parser'
|
||||
|
||||
export interface Annotation {
|
||||
message: string
|
||||
severity: string
|
||||
from: CodeMirror.Position
|
||||
to: CodeMirror.Position
|
||||
}
|
||||
|
||||
export interface Identifier extends Node {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Declaration extends Node {
|
||||
name: Identifier | string
|
||||
}
|
||||
|
||||
export class Linter {
|
||||
private readonly code: string
|
||||
|
||||
private readonly function_names: Set<string>
|
||||
|
||||
private readonly class_names: Set<string>
|
||||
|
||||
public readonly annotations: Annotation[]
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
* @param code
|
||||
*/
|
||||
constructor(code: string) {
|
||||
this.code = code
|
||||
this.annotations = []
|
||||
|
||||
this.function_names = new Set()
|
||||
this.class_names = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Lint the provided code.
|
||||
*/
|
||||
lint() {
|
||||
const parser = new Engine({
|
||||
parser: {
|
||||
suppressErrors: true,
|
||||
version: 800
|
||||
},
|
||||
ast: {
|
||||
withPositions: true
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const program = parser.parseEval(this.code)
|
||||
|
||||
if (0 < program.errors.length) {
|
||||
for (const error of program.errors) {
|
||||
this.annotate(error.message, error.loc)
|
||||
}
|
||||
}
|
||||
|
||||
this.visit(program)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit nodes recursively.
|
||||
* @param node
|
||||
*/
|
||||
visit(node: Node) {
|
||||
if (node.kind) {
|
||||
this.validate(node)
|
||||
}
|
||||
|
||||
if ('children' in node) {
|
||||
const block = <Block> node
|
||||
for (const child of block.children) {
|
||||
this.visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a given identifier has already been defined, creating an annotation if so.
|
||||
* @param identifier
|
||||
* @param registry
|
||||
* @param label
|
||||
*/
|
||||
checkDuplicateIdentifier(identifier: Identifier, registry: Set<string>, label: string) {
|
||||
if (registry.has(identifier.name)) {
|
||||
this.annotate(`Cannot redeclare ${label} ${identifier.name}()`, identifier.loc)
|
||||
} else {
|
||||
registry.add(identifier.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform additional validations on nodes.
|
||||
* @param node
|
||||
*/
|
||||
validate(node: Node) {
|
||||
const decl = <Declaration> node
|
||||
const ident = <Identifier> decl.name
|
||||
|
||||
if (!('name' in decl && 'name' in ident) || 'identifier' !== ident.kind) {
|
||||
return
|
||||
}
|
||||
|
||||
if ('function' === node.kind) {
|
||||
this.checkDuplicateIdentifier(ident, this.function_names, 'function')
|
||||
} else if ('class' === node.kind) {
|
||||
this.checkDuplicateIdentifier(ident, this.class_names, 'class')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a lint annotation.
|
||||
* @param message
|
||||
* @param location
|
||||
* @param severity
|
||||
*/
|
||||
annotate(message: string, location: Location | null, severity = 'error') {
|
||||
const [start, end] = location
|
||||
? location.end.offset < location.start.offset ? [location.end, location.start] : [location.start, location.end]
|
||||
: [{ line: 0, column: 0 }, { line: 0, column: 0 }]
|
||||
|
||||
this.annotations.push({
|
||||
message,
|
||||
severity,
|
||||
from: CodeMirror.Pos(start.line - 1, start.column),
|
||||
to: CodeMirror.Pos(end.line - 1, end.column)
|
||||
})
|
||||
}
|
||||
}
|
||||
3
plugins/code-snippets/js/utils/errors.ts
Normal file
3
plugins/code-snippets/js/utils/errors.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const handleUnknownError = (error: unknown) => {
|
||||
console.error(error)
|
||||
}
|
||||
43
plugins/code-snippets/js/utils/files.ts
Normal file
43
plugins/code-snippets/js/utils/files.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { getSnippetType } from './snippets/snippets'
|
||||
import type { SnippetsExport } from '../types/schema/SnippetsExport'
|
||||
import type { Snippet } from '../types/Snippet'
|
||||
|
||||
const SECOND_IN_MS = 1000
|
||||
const TIMEOUT_SECONDS = 40
|
||||
const JSON_INDENT_SPACES = 2
|
||||
|
||||
const MIME_INFO = <const> {
|
||||
php: ['php', 'text/php'],
|
||||
html: ['php', 'text/php'],
|
||||
css: ['css', 'text/css'],
|
||||
js: ['js', 'text/javascript'],
|
||||
cond: ['json', 'application/json'],
|
||||
json: ['json', 'application/json']
|
||||
}
|
||||
|
||||
export const downloadAsFile = (content: BlobPart, filename: string, type: string) => {
|
||||
const link = document.createElement('a')
|
||||
link.download = filename
|
||||
link.href = URL.createObjectURL(new Blob([content], { type }))
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), TIMEOUT_SECONDS * SECOND_IN_MS)
|
||||
setTimeout(() => link.click(), 0)
|
||||
}
|
||||
|
||||
export const downloadSnippetExportFile = (
|
||||
content: SnippetsExport | string,
|
||||
{ id, name, scope }: Snippet,
|
||||
type?: keyof typeof MIME_INFO
|
||||
) => {
|
||||
const sanitizedName = name.toLowerCase().replace(/[^\w-]+/g, '-').trim()
|
||||
const title = '' === sanitizedName ? `snippet-${id}` : sanitizedName
|
||||
|
||||
if ('string' === typeof content) {
|
||||
const [ext, mimeType] = MIME_INFO[type ?? getSnippetType({ scope })]
|
||||
const filename = `${title}.code-snippets.${ext}`
|
||||
downloadAsFile(content, filename, mimeType)
|
||||
} else {
|
||||
const filename = `${title}.code-snippets.json`
|
||||
downloadAsFile(JSON.stringify(content, undefined, JSON_INDENT_SPACES), filename, 'application/json')
|
||||
}
|
||||
}
|
||||
21
plugins/code-snippets/js/utils/hooks.ts
Normal file
21
plugins/code-snippets/js/utils/hooks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { Context } from 'react'
|
||||
|
||||
export const createContextHook = <T>(name: string): [
|
||||
Context<T | undefined>,
|
||||
() => T
|
||||
] => {
|
||||
const contextValue = createContext<T | undefined>(undefined)
|
||||
|
||||
const useContextHook = (): T => {
|
||||
const value = useContext(contextValue)
|
||||
|
||||
if (value === undefined) {
|
||||
throw Error(`use${name} can only be used within a ${name} context provider.`)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
return [contextValue, useContextHook]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user