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(
{
@ -214,25 +201,9 @@ module.exports = class ArticlesDBApi {
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(
{
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;
}
@ -382,32 +337,10 @@ module.exports = class ArticlesDBApi {
offset = currentPage * limit;
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,
as: 'featured_images',
},
];
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) {
const [start, end] = filter.createdAtRange;

View File

@ -1,272 +1,91 @@
import React from 'react';
import BaseIcon from '../BaseIcon';
import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js';
import axios from 'axios';
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 React from 'react'
import { GridRowParams } from '@mui/x-data-grid'
import ListActionsPopover from '../ListActionsPopover'
import { hasPermission } from '../../helpers/userPermissions'
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 (
onDelete: Params,
entityName: string,
user
) => {
async function callOptionsApi(entityName: string) {
if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return [];
try {
const data = await axios(`/${entityName}/autocomplete?limit=100`);
return data.data;
} catch (error) {
console.log(error);
return [];
}
}
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
return [
{
field: 'title',
headerName: 'Title',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'slug',
headerName: 'Slug',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'status',
headerName: 'Status',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'excerpt',
headerName: 'Excerpt',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
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>,
]
},
},
];
};
return [
{
field: 'title',
headerName: 'Title',
flex: 1,
minWidth: 180,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'status',
headerName: 'Status',
flex: 0.6,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'excerpt',
headerName: 'Excerpt',
flex: 1.2,
minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'content_html',
headerName: 'HTML Content',
flex: 1.2,
minWidth: 220,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'content_markdown',
headerName: 'Meta Paste',
flex: 1,
minWidth: 180,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'source_url',
headerName: 'Source URL',
flex: 1,
minWidth: 180,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'actions',
type: 'actions',
minWidth: 30,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
getActions: (params: GridRowParams) => [
<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 [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'},
{label: 'PublishedAt', title: 'published_at', date: 'true'},
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'},
{label: 'Status', title: 'status', type: 'enum', options: ['draft','published','archived']},
const [filters] = useState([
{ label: 'Title', title: 'title' },
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{ label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{ label: 'Meta Paste', title: 'content_markdown' },
{ label: 'Source URL', title: 'source_url' },
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton>
<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
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 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 LayoutAuthenticated from '../../layouts/Authenticated'
import FormField from '../../components/FormField'
import { RichTextField } from '../../components/RichTextField'
import SectionMain from '../../components/SectionMain'
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'
import LayoutAuthenticated from '../../layouts/Authenticated'
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 { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
title: '',
slug: '',
status: 'draft',
excerpt: '',
content_html: '',
content_markdown: '',
featured_images: [],
source_url: '',
published_at: '',
affiliate_links: [],
media_assets: [],
article_bundle_items: [],
title: '',
status: 'draft',
excerpt: '',
content_html: '',
content_markdown: '',
source_url: '',
}
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 router = useRouter()
const dispatch = useAppDispatch()
const [submitError, setSubmitError] = useState('')
const handleSubmit = async (data, { setSubmitting }) => {
setSubmitError('')
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/articles/articles-list')
try {
await dispatch(create(data)).unwrap()
await router.push('/articles/articles-list')
} catch (error) {
console.error('Article create failed:', error)
setSubmitError(getErrorMessage(error))
} finally {
setSubmitting(false)
}
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('New Article')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title='New Article' main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="Title"
>
<Field
name="title"
placeholder="Title"
/>
</FormField>
<FormField
label="Slug"
>
<Field
name="slug"
placeholder="Slug"
/>
</FormField>
<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 initialValues={initialValues} onSubmit={handleSubmit}>
{({ isSubmitting }) => (
<Form>
<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>
{submitError && (
<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'>
<Field name='title' placeholder='Article title' />
</FormField>
<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='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>
<BaseButton type='submit' color='info' label={isSubmitting ? 'Saving...' : 'Save Article'} disabled={isSubmitting} />
<BaseButton type='reset' color='info' outline label='Reset' disabled={isSubmitting} />
<BaseButton
type='button'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/articles/articles-list')}
disabled={isSubmitting}
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
@ -694,15 +131,7 @@ const ArticlesNew = () => {
}
ArticlesNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_ARTICLES'}
>
{page}
</LayoutAuthenticated>
)
return <LayoutAuthenticated permission='CREATE_ARTICLES'>{page}</LayoutAuthenticated>
}
export default ArticlesNew

View File

@ -34,13 +34,13 @@ const ArticlesTablesPage = () => {
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'},
{label: 'PublishedAt', title: 'published_at', date: 'true'},
{label: 'AffiliateLinks', title: 'affiliate_links'},{label: 'MediaAssets', title: 'media_assets'},{label: 'ArticleBundleItems', title: 'article_bundle_items'},
{label: 'Status', title: 'status', type: 'enum', options: ['draft','published','archived']},
const [filters] = useState([
{ label: 'Title', title: 'title' },
{ label: 'Status', title: 'status', type: 'enum', options: ['draft', 'published', 'archived'] },
{ label: 'Excerpt', title: 'excerpt' },
{ label: 'HTML Content', title: 'content_html' },
{ label: 'Meta Paste', title: 'content_markdown' },
{ label: 'Source URL', title: 'source_url' },
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ARTICLES');
@ -94,7 +94,7 @@ const ArticlesTablesPage = () => {
</SectionTitleLineWithButton>
<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
className={'mr-3'}