This commit is contained in:
Flatlogic Bot 2026-06-02 22:31:33 +00:00
parent f0df90f9b7
commit 407eb21fc4
6 changed files with 342 additions and 2064 deletions

View File

@ -70,19 +70,6 @@ module.exports = class ArticlesDBApi {
await articles.setAffiliate_links(data.affiliate_links || [], {
transaction,
});
await articles.setMedia_assets(data.media_assets || [], {
transaction,
});
await articles.setArticle_bundle_items(data.article_bundle_items || [], {
transaction,
});
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
@ -214,25 +201,9 @@ module.exports = class ArticlesDBApi {
await articles.update(updatePayload, {transaction}); await articles.update(updatePayload, {transaction});
// Article source-content updates intentionally skip generated many-to-many
// affiliate/media/bundle sync. Those joins are handled later in Export Studio.
if (data.affiliate_links !== undefined) {
await articles.setAffiliate_links(data.affiliate_links, { transaction });
}
if (data.media_assets !== undefined) {
await articles.setMedia_assets(data.media_assets, { transaction });
}
if (data.article_bundle_items !== undefined) {
await articles.setArticle_bundle_items(data.article_bundle_items, { transaction });
}
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
{ {
belongsTo: db.articles.getTableName(), belongsTo: db.articles.getTableName(),
@ -347,22 +318,6 @@ module.exports = class ArticlesDBApi {
}); });
output.affiliate_links = await articles.getAffiliate_links({
transaction
});
output.media_assets = await articles.getMedia_assets({
transaction
});
output.article_bundle_items = await articles.getArticle_bundle_items({
transaction
});
return output; return output;
} }
@ -382,32 +337,10 @@ module.exports = class ArticlesDBApi {
offset = currentPage * limit; offset = currentPage * limit;
let include = [ let include = [
{
model: db.affiliate_links,
as: 'affiliate_links',
required: false,
},
{
model: db.media_assets,
as: 'media_assets',
required: false,
},
{
model: db.article_bundle_items,
as: 'article_bundle_items',
required: false,
},
{ {
model: db.file, model: db.file,
as: 'featured_images', as: 'featured_images',
}, },
]; ];
if (filter) { if (filter) {
@ -533,76 +466,6 @@ module.exports = class ArticlesDBApi {
if (filter.affiliate_links) {
const searchTerms = filter.affiliate_links.split('|');
include = [
{
model: db.affiliate_links,
as: 'affiliate_links_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
original_url: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.media_assets) {
const searchTerms = filter.media_assets.split('|');
include = [
{
model: db.media_assets,
as: 'media_assets_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
file_name: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.article_bundle_items) {
const searchTerms = filter.article_bundle_items.split('|');
include = [
{
model: db.article_bundle_items,
as: 'article_bundle_items_filter',
required: searchTerms.length > 0,
where: searchTerms.length > 0 ? {
[Op.or]: [
{ id: { [Op.in]: searchTerms.map(term => Utils.uuid(term)) } },
{
sort_order: {
[Op.or]: searchTerms.map(term => ({ [Op.iLike]: `%${term}%` }))
}
}
]
} : undefined
},
...include,
]
}
if (filter.createdAtRange) { if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange; const [start, end] = filter.createdAtRange;

View File

@ -1,272 +1,91 @@
import React from 'react'; import React from 'react'
import BaseIcon from '../BaseIcon'; import { GridRowParams } from '@mui/x-data-grid'
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; import ListActionsPopover from '../ListActionsPopover'
import axios from 'axios'; import { hasPermission } from '../../helpers/userPermissions'
import {
GridActionsCellItem,
GridRowParams,
GridValueGetterParams,
} from '@mui/x-data-grid';
import ImageField from '../ImageField';
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter'
import DataGridMultiSelect from "../DataGridMultiSelect";
import ListActionsPopover from '../ListActionsPopover';
import {hasPermission} from "../../helpers/userPermissions"; type Params = (id: string) => void
type Params = (id: string) => void; export const loadColumns = async (onDelete: Params, _entityName: string, user) => {
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
export const loadColumns = async ( return [
onDelete: Params, {
entityName: string, field: 'title',
headerName: 'Title',
user flex: 1,
minWidth: 180,
) => { filterable: false,
async function callOptionsApi(entityName: string) { headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; editable: hasUpdatePermission,
},
try { {
const data = await axios(`/${entityName}/autocomplete?limit=100`); field: 'status',
return data.data; headerName: 'Status',
} catch (error) { flex: 0.6,
console.log(error); minWidth: 120,
return []; filterable: false,
} headerClassName: 'datagrid--header',
} cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES') },
{
return [ field: 'excerpt',
headerName: 'Excerpt',
{ flex: 1.2,
field: 'title', minWidth: 220,
headerName: 'Title', filterable: false,
flex: 1, headerClassName: 'datagrid--header',
minWidth: 120, cellClassName: 'datagrid--cell',
filterable: false, editable: hasUpdatePermission,
headerClassName: 'datagrid--header', },
cellClassName: 'datagrid--cell', {
field: 'content_html',
headerName: 'HTML Content',
editable: hasUpdatePermission, flex: 1.2,
minWidth: 220,
filterable: false,
}, headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
{ editable: hasUpdatePermission,
field: 'slug', },
headerName: 'Slug', {
flex: 1, field: 'content_markdown',
minWidth: 120, headerName: 'Meta Paste',
filterable: false, flex: 1,
headerClassName: 'datagrid--header', minWidth: 180,
cellClassName: 'datagrid--cell', filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission, editable: hasUpdatePermission,
},
{
}, field: 'source_url',
headerName: 'Source URL',
{ flex: 1,
field: 'status', minWidth: 180,
headerName: 'Status', filterable: false,
flex: 1, headerClassName: 'datagrid--header',
minWidth: 120, cellClassName: 'datagrid--cell',
filterable: false, editable: hasUpdatePermission,
headerClassName: 'datagrid--header', },
cellClassName: 'datagrid--cell', {
field: 'actions',
type: 'actions',
editable: hasUpdatePermission, minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
}, getActions: (params: GridRowParams) => [
<div key={params?.row?.id}>
{ <ListActionsPopover
field: 'excerpt', onDelete={onDelete}
headerName: 'Excerpt', itemId={params?.row?.id}
flex: 1, pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
minWidth: 120, pathView={`/articles/articles-view/?id=${params?.row?.id}`}
filterable: false, hasUpdatePermission={hasUpdatePermission}
headerClassName: 'datagrid--header', />
cellClassName: 'datagrid--cell', </div>,
],
},
editable: hasUpdatePermission, ]
}
},
{
field: 'content_html',
headerName: 'ContentHTML',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'content_markdown',
headerName: 'ContentMarkdown',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'featured_images',
headerName: 'FeaturedImages',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
renderCell: (params: GridValueGetterParams) => (
<ImageField
name={'Avatar'}
image={params?.row?.featured_images}
className='w-24 h-24 mx-auto lg:w-6 lg:h-6'
/>
),
},
{
field: 'source_url',
headerName: 'SourceURL',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'published_at',
headerName: 'PublishedAt',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.published_at),
},
{
field: 'affiliate_links',
headerName: 'AffiliateLinks',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.affiliate_linksManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'affiliate_links'}/>
),
},
{
field: 'media_assets',
headerName: 'MediaAssets',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.media_assetsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'media_assets'}/>
),
},
{
field: 'article_bundle_items',
headerName: 'ArticleBundleItems',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: false,
sortable: false,
type: 'singleSelect',
valueFormatter: ({ value }) =>
dataFormatter.article_bundle_itemsManyListFormatter(value).join(', '),
renderEditCell: (params) => (
<DataGridMultiSelect {...params} entityName={'article_bundle_items'}/>
),
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => {
return [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
pathView={`/articles/articles-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
]
},
},
];
};

File diff suppressed because it is too large Load Diff

View File

@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Slug', title: 'slug'},{label: 'Excerpt', title: 'excerpt'},{label: 'ContentHTML', title: 'content_html'},{label: 'ContentMarkdown', title: 'content_markdown'},{label: 'SourceURL', title: 'source_url'}, const [filters] = useState([
{ label: 'Title', title: 'title' },
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{label: 'PublishedAt', title: 'published_at', date: 'true'}, { label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'}, { label: 'Meta Paste', title: 'content_markdown' },
{label: 'Status', title: 'status', type: 'enum', options: ['draft','published','archived']}, { label: 'Source URL', title: 'source_url' },
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}

View File

@ -1,691 +1,128 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' import { mdiChartTimelineVariant } from '@mdi/js'
import Head from 'next/head' import Head from 'next/head'
import React, { ReactElement } from 'react' import React, { ReactElement, useState } from 'react'
import { Field, Form, Formik } from 'formik'
import { useRouter } from 'next/router'
import BaseButton from '../../components/BaseButton'
import BaseButtons from '../../components/BaseButtons'
import BaseDivider from '../../components/BaseDivider'
import CardBox from '../../components/CardBox' import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated' import FormField from '../../components/FormField'
import { RichTextField } from '../../components/RichTextField'
import SectionMain from '../../components/SectionMain' import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../../layouts/Authenticated'
import { getPageTitle } from '../../config' import { getPageTitle } from '../../config'
import { Field, Form, Formik } from 'formik'
import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/articles/articlesSlice' import { create } from '../../stores/articles/articlesSlice'
import { useAppDispatch } from '../../stores/hooks' import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = { const initialValues = {
title: '',
status: 'draft',
title: '', excerpt: '',
content_html: '',
content_markdown: '',
source_url: '',
slug: '',
status: 'draft',
excerpt: '',
content_html: '',
content_markdown: '',
featured_images: [],
source_url: '',
published_at: '',
affiliate_links: [],
media_assets: [],
article_bundle_items: [],
} }
const getErrorMessage = (error: any) => {
if (!error) return 'Article could not be saved. Please check the form and try again.'
if (typeof error === 'string') return error
if (error.message) return error.message
if (error.error) return error.error
return 'Article could not be saved. Please check the form and try again.'
}
const ArticlesNew = () => { const ArticlesNew = () => {
const router = useRouter() const router = useRouter()
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const [submitError, setSubmitError] = useState('')
const handleSubmit = async (data, { setSubmitting }) => {
setSubmitError('')
const handleSubmit = async (data) => { try {
await dispatch(create(data)) await dispatch(create(data)).unwrap()
await router.push('/articles/articles-list') await router.push('/articles/articles-list')
} catch (error) {
console.error('Article create failed:', error)
setSubmitError(getErrorMessage(error))
} finally {
setSubmitting(false)
}
} }
return ( return (
<> <>
<Head> <Head>
<title>{getPageTitle('New Item')}</title> <title>{getPageTitle('New Article')}</title>
</Head> </Head>
<SectionMain> <SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main> <SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='New Article' main>
{''} {''}
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox> <CardBox>
<Formik <Formik initialValues={initialValues} onSubmit={handleSubmit}>
initialValues={ {({ isSubmitting }) => (
<Form>
initialValues <p className='mb-4 text-sm text-gray-600 dark:text-dark-600'>
Add the source article here. Buyer-specific affiliate link replacement happens later in Export Studio.
} </p>
onSubmit={(values) => handleSubmit(values)}
> {submitError && (
<Form> <div className='mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700'>
{submitError}
</div>
)}
<FormField
label="Title" <FormField label='Title'>
> <Field name='title' placeholder='Article title' />
<Field </FormField>
name="title"
placeholder="Title" <FormField label='Status' labelFor='status'>
/> <Field name='status' id='status' component='select'>
</FormField> <option value='draft'>Draft</option>
<option value='published'>Published</option>
<option value='archived'>Archived</option>
</Field>
</FormField>
<FormField label='Excerpt' hasTextareaHeight>
<Field name='excerpt' as='textarea' placeholder='Optional short summary for the article list/export screen' />
</FormField>
<FormField label='HTML Content' hasTextareaHeight>
<Field name='content_html' id='content_html' component={RichTextField} />
</FormField>
<FormField label='Meta Paste' hasTextareaHeight>
<Field
name='content_markdown'
as='textarea'
placeholder='Paste your metadata, notes, markdown, or source details here'
/>
</FormField>
<FormField label='Source URL'>
<Field name='source_url' placeholder='Optional source URL' />
</FormField>
<BaseDivider />
<BaseButtons>
<FormField <BaseButton type='submit' color='info' label={isSubmitting ? 'Saving...' : 'Save Article'} disabled={isSubmitting} />
label="Slug" <BaseButton type='reset' color='info' outline label='Reset' disabled={isSubmitting} />
> <BaseButton
<Field type='button'
name="slug" color='danger'
placeholder="Slug" outline
/> label='Cancel'
</FormField> onClick={() => router.push('/articles/articles-list')}
disabled={isSubmitting}
/>
</BaseButtons>
</Form>
)}
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">
<option value="draft">draft</option>
<option value="published">published</option>
<option value="archived">archived</option>
</Field>
</FormField>
<FormField label="Excerpt" hasTextareaHeight>
<Field name="excerpt" as="textarea" placeholder="Excerpt" />
</FormField>
<FormField label='ContentHTML' hasTextareaHeight>
<Field
name='content_html'
id='content_html'
component={RichTextField}
></Field>
</FormField>
<FormField label="ContentMarkdown" hasTextareaHeight>
<Field name="content_markdown" as="textarea" placeholder="ContentMarkdown" />
</FormField>
<FormField>
<Field
label='FeaturedImages'
color='info'
icon={mdiUpload}
path={'articles/featured_images'}
name='featured_images'
id='featured_images'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField
label="SourceURL"
>
<Field
name="source_url"
placeholder="SourceURL"
/>
</FormField>
<FormField
label="PublishedAt"
>
<Field
type="datetime-local"
name="published_at"
placeholder="PublishedAt"
/>
</FormField>
<FormField label='AffiliateLinks' labelFor='affiliate_links'>
<Field
name='affiliate_links'
id='affiliate_links'
itemRef={'affiliate_links'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='MediaAssets' labelFor='media_assets'>
<Field
name='media_assets'
id='media_assets'
itemRef={'media_assets'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<FormField label='ArticleBundleItems' labelFor='article_bundle_items'>
<Field
name='article_bundle_items'
id='article_bundle_items'
itemRef={'article_bundle_items'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/articles/articles-list')}/>
</BaseButtons>
</Form>
</Formik> </Formik>
</CardBox> </CardBox>
</SectionMain> </SectionMain>
@ -694,15 +131,7 @@ const ArticlesNew = () => {
} }
ArticlesNew.getLayout = function getLayout(page: ReactElement) { ArticlesNew.getLayout = function getLayout(page: ReactElement) {
return ( return <LayoutAuthenticated permission='CREATE_ARTICLES'>{page}</LayoutAuthenticated>
<LayoutAuthenticated
permission={'CREATE_ARTICLES'}
>
{page}
</LayoutAuthenticated>
)
} }
export default ArticlesNew export default ArticlesNew

View File

@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [filters] = useState([{label: 'Title', title: 'title'},{label: 'Slug', title: 'slug'},{label: 'Excerpt', title: 'excerpt'},{label: 'ContentHTML', title: 'content_html'},{label: 'ContentMarkdown', title: 'content_markdown'},{label: 'SourceURL', title: 'source_url'}, const [filters] = useState([
{ label: 'Title', title: 'title' },
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{label: 'PublishedAt', title: 'published_at', date: 'true'}, { label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'}, { label: 'Meta Paste', title: 'content_markdown' },
{label: 'Status', title: 'status', type: 'enum', options: ['draft','published','archived']}, { label: 'Source URL', title: 'source_url' },
]); ]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES'); const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton> </SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'> <CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Item'/>} {hasCreatePermission && <BaseButton className={'mr-3'} href={'/articles/articles-new'} color='info' label='New Article'/>}
<BaseButton <BaseButton
className={'mr-3'} className={'mr-3'}