Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
407eb21fc4 | ||
|
|
f0df90f9b7 |
@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
const FileDBApi = require('./file');
|
const FileDBApi = require('./file');
|
||||||
const crypto = require('crypto');
|
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
|
|
||||||
@ -71,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(
|
||||||
{
|
{
|
||||||
@ -215,24 +201,8 @@ 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(
|
||||||
{
|
{
|
||||||
@ -348,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,37 +336,11 @@ module.exports = class ArticlesDBApi {
|
|||||||
|
|
||||||
offset = currentPage * limit;
|
offset = currentPage * limit;
|
||||||
|
|
||||||
const orderBy = null;
|
|
||||||
|
|
||||||
const transaction = (options && options.transaction) || undefined;
|
|
||||||
|
|
||||||
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) {
|
||||||
@ -538,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;
|
||||||
|
|
||||||
@ -665,6 +523,41 @@ module.exports = class ArticlesDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async findForExportStudio(filter = {}, options = {}) {
|
||||||
|
const transaction = (options && options.transaction) || undefined;
|
||||||
|
const limit = Math.min(Number(filter.limit) || 50, 100);
|
||||||
|
const currentPage = Number(filter.page) || 0;
|
||||||
|
const offset = currentPage * limit;
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
where.status = filter.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, count } = await db.articles.findAndCountAll({
|
||||||
|
attributes: [
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'status',
|
||||||
|
'excerpt',
|
||||||
|
'content_html',
|
||||||
|
'content_markdown',
|
||||||
|
'source_url',
|
||||||
|
'published_at',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
],
|
||||||
|
where,
|
||||||
|
order: [['updatedAt', 'desc']],
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { rows, count };
|
||||||
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(query, limit, offset, ) {
|
static async findAllAutocomplete(query, limit, offset, ) {
|
||||||
let where = {};
|
let where = {};
|
||||||
|
|
||||||
|
|||||||
@ -100,10 +100,7 @@ const ArticlesData = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"content_markdown": "# Best Budget Laptops for Remote Work
|
"content_markdown": "# Best Budget Laptops for Remote Work\nWorking from home is easier with the right laptop.\n\nTop pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALTAG)",
|
||||||
Working from home is easier with the right laptop.
|
|
||||||
|
|
||||||
Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALTAG)",
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -191,9 +188,7 @@ Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALT
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"content_markdown": "# Home Office Essentials Checklist
|
"content_markdown": "# Home Office Essentials Checklist\n- [Standing Desk](https://example.com/affiliate/standing-desk?tag=ORIGINALTAG)\n- [Ergonomic Chair](https://example.com/affiliate/ergonomic-chair?tag=ORIGINALTAG)",
|
||||||
- [Standing Desk](https://example.com/affiliate/standing-desk?tag=ORIGINALTAG)
|
|
||||||
- [Ergonomic Chair](https://example.com/affiliate/ergonomic-chair?tag=ORIGINALTAG)",
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -281,8 +276,7 @@ Top pick: [Budget Laptop A](https://example.com/affiliate/laptop-1?tag=ORIGINALT
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
"content_markdown": "# Beginner Guide to Email Marketing Tools
|
"content_markdown": "# Beginner Guide to Email Marketing Tools\nStarter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
||||||
Starter option: [Email Platform X](https://example.com/affiliate/email-platform?ref=ORIGINALTAG)",
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3345,7 +3339,7 @@ const ExportsData = [
|
|||||||
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
up: async (queryInterface, Sequelize) => {
|
up: async () => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -3742,7 +3736,7 @@ module.exports = {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
down: async (queryInterface, Sequelize) => {
|
down: async (queryInterface) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -324,6 +324,16 @@ router.get('/', wrapAsync(async (req, res) => {
|
|||||||
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.get('/export-studio/source-articles', wrapAsync(async (req, res) => {
|
||||||
|
const currentUser = req.currentUser;
|
||||||
|
const payload = await ArticlesDBApi.findForExportStudio(
|
||||||
|
req.query,
|
||||||
|
{ currentUser },
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/articles/count:
|
* /api/articles/count:
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|
||||||
) => {
|
|
||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export const loadColumns = async (onDelete: Params, _entityName: string, user) => {
|
||||||
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
|
const hasUpdatePermission = hasPermission(user, 'UPDATE_ARTICLES')
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'title',
|
field: 'title',
|
||||||
headerName: 'Title',
|
headerName: 'Title',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 180,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
|
||||||
field: 'slug',
|
|
||||||
headerName: 'Slug',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: 120,
|
|
||||||
filterable: false,
|
|
||||||
headerClassName: 'datagrid--header',
|
|
||||||
cellClassName: 'datagrid--cell',
|
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
|
||||||
|
|
||||||
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'status',
|
||||||
headerName: 'Status',
|
headerName: 'Status',
|
||||||
flex: 1,
|
flex: 0.6,
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'excerpt',
|
field: 'excerpt',
|
||||||
headerName: 'Excerpt',
|
headerName: 'Excerpt',
|
||||||
flex: 1,
|
flex: 1.2,
|
||||||
minWidth: 120,
|
minWidth: 220,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'content_html',
|
field: 'content_html',
|
||||||
headerName: 'ContentHTML',
|
headerName: 'HTML Content',
|
||||||
flex: 1,
|
flex: 1.2,
|
||||||
minWidth: 120,
|
minWidth: 220,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
editable: hasUpdatePermission,
|
||||||
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
field: 'content_markdown',
|
field: 'content_markdown',
|
||||||
headerName: 'ContentMarkdown',
|
headerName: 'Meta Paste',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 180,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
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',
|
field: 'source_url',
|
||||||
headerName: 'Source URL',
|
headerName: 'Source URL',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 120,
|
minWidth: 180,
|
||||||
filterable: false,
|
filterable: false,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
|
|
||||||
|
|
||||||
editable: hasUpdatePermission,
|
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',
|
field: 'actions',
|
||||||
type: 'actions',
|
type: 'actions',
|
||||||
minWidth: 30,
|
minWidth: 30,
|
||||||
headerClassName: 'datagrid--header',
|
headerClassName: 'datagrid--header',
|
||||||
cellClassName: 'datagrid--cell',
|
cellClassName: 'datagrid--cell',
|
||||||
getActions: (params: GridRowParams) => {
|
getActions: (params: GridRowParams) => [
|
||||||
|
|
||||||
return [
|
|
||||||
<div key={params?.row?.id}>
|
<div key={params?.row?.id}>
|
||||||
<ListActionsPopover
|
<ListActionsPopover
|
||||||
onDelete={onDelete}
|
onDelete={onDelete}
|
||||||
itemId={params?.row?.id}
|
itemId={params?.row?.id}
|
||||||
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
|
pathEdit={`/articles/articles-edit/?id=${params?.row?.id}`}
|
||||||
pathView={`/articles/articles-view/?id=${params?.row?.id}`}
|
pathView={`/articles/articles-view/?id=${params?.row?.id}`}
|
||||||
|
|
||||||
hasUpdatePermission={hasUpdatePermission}
|
hasUpdatePermission={hasUpdatePermission}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
389
frontend/src/helpers/affiliateLinks.ts
Normal file
389
frontend/src/helpers/affiliateLinks.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
export type LinkSource = 'html' | 'markdown' | 'raw';
|
||||||
|
|
||||||
|
export type AffiliateParameterMatch = {
|
||||||
|
parameter: string;
|
||||||
|
value: string;
|
||||||
|
replacementKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DetectedContentLink = {
|
||||||
|
id: string;
|
||||||
|
originalUrl: string;
|
||||||
|
parsedUrl: string;
|
||||||
|
linkText: string;
|
||||||
|
domain: string;
|
||||||
|
network: string;
|
||||||
|
source: LinkSource;
|
||||||
|
parameters: AffiliateParameterMatch[];
|
||||||
|
isAffiliateCandidate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AffiliateReplacementGroup = {
|
||||||
|
key: string;
|
||||||
|
domain: string;
|
||||||
|
network: string;
|
||||||
|
parameter: string;
|
||||||
|
currentValue: string;
|
||||||
|
linkCount: number;
|
||||||
|
sampleUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AffiliateDetectionResult = {
|
||||||
|
links: DetectedContentLink[];
|
||||||
|
affiliateLinks: DetectedContentLink[];
|
||||||
|
groups: AffiliateReplacementGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExtractedLink = {
|
||||||
|
originalUrl: string;
|
||||||
|
linkText: string;
|
||||||
|
source: LinkSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AffiliateNetworkRule = {
|
||||||
|
label: string;
|
||||||
|
domains: string[];
|
||||||
|
parameters: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const genericAffiliateParameters = new Set([
|
||||||
|
'tag',
|
||||||
|
'ascsubtag',
|
||||||
|
'aff',
|
||||||
|
'affid',
|
||||||
|
'affiliate',
|
||||||
|
'affiliateid',
|
||||||
|
'affiliate_id',
|
||||||
|
'ref',
|
||||||
|
'refid',
|
||||||
|
'ref_id',
|
||||||
|
'partner',
|
||||||
|
'partnerid',
|
||||||
|
'partner_id',
|
||||||
|
'campid',
|
||||||
|
'campaign',
|
||||||
|
'sid',
|
||||||
|
'subid',
|
||||||
|
'sub_id',
|
||||||
|
'sub1',
|
||||||
|
'sub2',
|
||||||
|
'sub3',
|
||||||
|
'clickid',
|
||||||
|
'irclickid',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const affiliateNetworkRules: AffiliateNetworkRule[] = [
|
||||||
|
{
|
||||||
|
label: 'Amazon Associates',
|
||||||
|
domains: ['amazon.', 'amzn.to'],
|
||||||
|
parameters: ['tag', 'ascsubtag'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ShareASale',
|
||||||
|
domains: ['shareasale.com'],
|
||||||
|
parameters: ['u', 'afftrack'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Awin',
|
||||||
|
domains: ['awin1.com', 'awstrack.me'],
|
||||||
|
parameters: ['awinaffid', 'clickref', 'p'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Rakuten Advertising',
|
||||||
|
domains: ['click.linksynergy.com', 'linksynergy.com'],
|
||||||
|
parameters: ['id', 'u1'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'CJ Affiliate',
|
||||||
|
domains: [
|
||||||
|
'anrdoezrs.net',
|
||||||
|
'dpbolvw.net',
|
||||||
|
'jdoqocy.com',
|
||||||
|
'kqzyfj.com',
|
||||||
|
'tkqlhce.com',
|
||||||
|
],
|
||||||
|
parameters: ['sid', 'cjevent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Impact',
|
||||||
|
domains: ['impact.com', 'impactradius.com'],
|
||||||
|
parameters: ['subid1', 'subid2', 'subid3', 'irclickid'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Example affiliate merchant',
|
||||||
|
domains: ['merchant.example'],
|
||||||
|
parameters: ['aff'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stripTags = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/<[^>]*>/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const decodeBasicHtmlEntities = (value: string) =>
|
||||||
|
value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
|
||||||
|
const makeReplacementKey = (
|
||||||
|
domain: string,
|
||||||
|
parameter: string,
|
||||||
|
currentValue: string,
|
||||||
|
) =>
|
||||||
|
[domain, parameter, currentValue]
|
||||||
|
.map((part) => encodeURIComponent(part))
|
||||||
|
.join('|');
|
||||||
|
|
||||||
|
const getUrlForParsing = (url: string) => {
|
||||||
|
const decodedUrl = decodeBasicHtmlEntities(url.trim());
|
||||||
|
|
||||||
|
if (decodedUrl.startsWith('//')) {
|
||||||
|
return `https:${decodedUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodedUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getParsedUrl = (url: string) => {
|
||||||
|
const parseableUrl = getUrlForParsing(url);
|
||||||
|
|
||||||
|
if (!/^https?:\/\//i.test(parseableUrl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(parseableUrl);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse article link URL', { url, error });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNetworkRule = (domain: string) => {
|
||||||
|
const normalizedDomain = domain.toLowerCase();
|
||||||
|
|
||||||
|
return affiliateNetworkRules.find((rule) =>
|
||||||
|
rule.domains.some((knownDomain) => normalizedDomain.includes(knownDomain)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAffiliateParameter = (
|
||||||
|
parameter: string,
|
||||||
|
networkRule?: AffiliateNetworkRule,
|
||||||
|
) => {
|
||||||
|
const normalizedParameter = parameter.toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
genericAffiliateParameters.has(normalizedParameter) ||
|
||||||
|
Boolean(
|
||||||
|
networkRule?.parameters.some(
|
||||||
|
(networkParameter) =>
|
||||||
|
networkParameter.toLowerCase() === normalizedParameter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dedupeExtractedLinks = (links: ExtractedLink[]) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
return links.filter((link) => {
|
||||||
|
const key = getUrlForParsing(link.originalUrl);
|
||||||
|
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractLinks = (content: string) => {
|
||||||
|
const links: ExtractedLink[] = [];
|
||||||
|
const htmlLinkPattern =
|
||||||
|
/<a\b[^>]*?\bhref\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
|
||||||
|
const markdownLinkPattern =
|
||||||
|
/(!)?\[([^\]]+)]\((<[^>]+>|[^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
|
||||||
|
const rawUrlPattern = /https?:\/\/[^\s"'<>)]*/g;
|
||||||
|
|
||||||
|
for (const match of content.matchAll(htmlLinkPattern)) {
|
||||||
|
links.push({
|
||||||
|
originalUrl: match[2],
|
||||||
|
linkText: stripTags(match[3]),
|
||||||
|
source: 'html',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of content.matchAll(markdownLinkPattern)) {
|
||||||
|
if (match[1]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
links.push({
|
||||||
|
originalUrl: match[3].replace(/^<|>$/g, ''),
|
||||||
|
linkText: match[2],
|
||||||
|
source: 'markdown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of content.matchAll(rawUrlPattern)) {
|
||||||
|
links.push({
|
||||||
|
originalUrl: match[0],
|
||||||
|
linkText: 'Raw URL',
|
||||||
|
source: 'raw',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeExtractedLinks(links);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUrlForOriginalSource = (
|
||||||
|
link: DetectedContentLink,
|
||||||
|
updatedUrl: string,
|
||||||
|
) => {
|
||||||
|
if (link.source === 'html' && link.originalUrl.includes('&')) {
|
||||||
|
return updatedUrl.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceEvery = (
|
||||||
|
content: string,
|
||||||
|
searchValue: string,
|
||||||
|
replacementValue: string,
|
||||||
|
) => content.split(searchValue).join(replacementValue);
|
||||||
|
|
||||||
|
export const detectAffiliateLinks = (
|
||||||
|
content: string,
|
||||||
|
): AffiliateDetectionResult => {
|
||||||
|
const extractedLinks = extractLinks(content);
|
||||||
|
const groupMap = new Map<string, AffiliateReplacementGroup>();
|
||||||
|
|
||||||
|
const links = extractedLinks
|
||||||
|
.map((link, index): DetectedContentLink | null => {
|
||||||
|
const parsedUrl = getParsedUrl(link.originalUrl);
|
||||||
|
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = parsedUrl.hostname.replace(/^www\./, '');
|
||||||
|
const networkRule = getNetworkRule(domain);
|
||||||
|
const parameters: AffiliateParameterMatch[] = [];
|
||||||
|
|
||||||
|
parsedUrl.searchParams.forEach((value, parameter) => {
|
||||||
|
if (!isAffiliateParameter(parameter, networkRule)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters.push({
|
||||||
|
parameter,
|
||||||
|
value,
|
||||||
|
replacementKey: makeReplacementKey(domain, parameter, value),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedLink: DetectedContentLink = {
|
||||||
|
id: `${index}-${domain}`,
|
||||||
|
originalUrl: link.originalUrl,
|
||||||
|
parsedUrl: getUrlForParsing(link.originalUrl),
|
||||||
|
linkText: link.linkText,
|
||||||
|
domain,
|
||||||
|
network: networkRule?.label ?? 'Detected link',
|
||||||
|
source: link.source,
|
||||||
|
parameters,
|
||||||
|
isAffiliateCandidate: parameters.length > 0 || Boolean(networkRule),
|
||||||
|
};
|
||||||
|
|
||||||
|
parameters.forEach((match) => {
|
||||||
|
const existingGroup = groupMap.get(match.replacementKey);
|
||||||
|
|
||||||
|
if (existingGroup) {
|
||||||
|
existingGroup.linkCount += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
groupMap.set(match.replacementKey, {
|
||||||
|
key: match.replacementKey,
|
||||||
|
domain,
|
||||||
|
network: detectedLink.network,
|
||||||
|
parameter: match.parameter,
|
||||||
|
currentValue: match.value,
|
||||||
|
linkCount: 1,
|
||||||
|
sampleUrl: link.originalUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return detectedLink;
|
||||||
|
})
|
||||||
|
.filter((link): link is DetectedContentLink => Boolean(link));
|
||||||
|
|
||||||
|
return {
|
||||||
|
links,
|
||||||
|
affiliateLinks: links.filter((link) => link.isAffiliateCandidate),
|
||||||
|
groups: Array.from(groupMap.values()),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyAffiliateReplacements = (
|
||||||
|
content: string,
|
||||||
|
links: DetectedContentLink[],
|
||||||
|
tagReplacements: Record<string, string>,
|
||||||
|
urlOverrides: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
let customizedContent = content;
|
||||||
|
|
||||||
|
links.forEach((link) => {
|
||||||
|
const urlOverride = urlOverrides[link.originalUrl]?.trim();
|
||||||
|
|
||||||
|
if (urlOverride) {
|
||||||
|
customizedContent = replaceEvery(
|
||||||
|
customizedContent,
|
||||||
|
link.originalUrl,
|
||||||
|
urlOverride,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (link.parameters.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUrl = getParsedUrl(link.originalUrl);
|
||||||
|
|
||||||
|
if (!parsedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasReplacement = false;
|
||||||
|
|
||||||
|
link.parameters.forEach((match) => {
|
||||||
|
const replacementValue = tagReplacements[match.replacementKey]?.trim();
|
||||||
|
|
||||||
|
if (!replacementValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedUrl.searchParams.set(match.parameter, replacementValue);
|
||||||
|
hasReplacement = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasReplacement) {
|
||||||
|
customizedContent = replaceEvery(
|
||||||
|
customizedContent,
|
||||||
|
link.originalUrl,
|
||||||
|
formatUrlForOriginalSource(link, parsedUrl.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return customizedContent;
|
||||||
|
};
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -8,6 +8,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
href: '/export-studio',
|
||||||
|
icon: icon.mdiFileSwapOutline,
|
||||||
|
label: 'Export Studio',
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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: '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'] },
|
{ 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');
|
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'}
|
||||||
|
|||||||
@ -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: '',
|
title: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
slug: '',
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
content_html: '',
|
content_html: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
content_markdown: '',
|
content_markdown: '',
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
featured_images: [],
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
source_url: '',
|
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('')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dispatch(create(data)).unwrap()
|
||||||
const handleSubmit = async (data) => {
|
|
||||||
await dispatch(create(data))
|
|
||||||
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 }) => (
|
||||||
|
|
||||||
initialValues
|
|
||||||
|
|
||||||
}
|
|
||||||
onSubmit={(values) => handleSubmit(values)}
|
|
||||||
>
|
|
||||||
<Form>
|
<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'>
|
||||||
<FormField
|
<Field name='title' placeholder='Article title' />
|
||||||
label="Title"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
name="title"
|
|
||||||
placeholder="Title"
|
|
||||||
/>
|
|
||||||
</FormField>
|
</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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<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>
|
</Field>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label='Excerpt' hasTextareaHeight>
|
||||||
|
<Field name='excerpt' as='textarea' placeholder='Optional short summary for the article list/export screen' />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label="Excerpt" hasTextareaHeight>
|
|
||||||
<Field name="excerpt" as="textarea" placeholder="Excerpt" />
|
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label='HTML Content' hasTextareaHeight>
|
||||||
|
<Field name='content_html' id='content_html' component={RichTextField} />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label='Meta Paste' hasTextareaHeight>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField label='ContentHTML' hasTextareaHeight>
|
|
||||||
<Field
|
<Field
|
||||||
name='content_html'
|
name='content_markdown'
|
||||||
id='content_html'
|
as='textarea'
|
||||||
component={RichTextField}
|
placeholder='Paste your metadata, notes, markdown, or source details here'
|
||||||
></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>
|
||||||
|
|
||||||
|
<FormField label='Source URL'>
|
||||||
|
<Field name='source_url' placeholder='Optional source URL' />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="PublishedAt"
|
|
||||||
>
|
|
||||||
<Field
|
|
||||||
type="datetime-local"
|
|
||||||
name="published_at"
|
|
||||||
placeholder="PublishedAt"
|
|
||||||
/>
|
|
||||||
</FormField>
|
</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 />
|
<BaseDivider />
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
<BaseButton type="submit" color="info" label="Submit" />
|
<BaseButton type='submit' color='info' label={isSubmitting ? 'Saving...' : 'Save Article'} disabled={isSubmitting} />
|
||||||
<BaseButton type="reset" color="info" outline label="Reset" />
|
<BaseButton type='reset' color='info' outline label='Reset' disabled={isSubmitting} />
|
||||||
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/articles/articles-list')}/>
|
<BaseButton
|
||||||
|
type='button'
|
||||||
|
color='danger'
|
||||||
|
outline
|
||||||
|
label='Cancel'
|
||||||
|
onClick={() => router.push('/articles/articles-list')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
</BaseButtons>
|
</BaseButtons>
|
||||||
</Form>
|
</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
|
||||||
|
|||||||
@ -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: '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'] },
|
{ 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');
|
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'}
|
||||||
|
|||||||
1002
frontend/src/pages/export-studio.tsx
Normal file
1002
frontend/src/pages/export-studio.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,161 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { mdiArrowRight, mdiCheckCircleOutline, mdiDownloadBoxOutline, mdiFileSwapOutline, mdiLinkVariant } from '@mdi/js';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const workflowSteps = [
|
||||||
|
'Preload WordPress-ready articles with your original affiliate links.',
|
||||||
|
'Sell access to bundles through your funnel and send buyers into the portal.',
|
||||||
|
'Buyers paste their own affiliate URLs and export a publish-ready file.',
|
||||||
|
];
|
||||||
|
|
||||||
|
const exportOptions = ['WordPress WXR/XML', 'Clean HTML', 'Markdown'];
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'Affiliate Article Licensing'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
return (
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
<div className="min-h-screen bg-[#F7F3EA] text-slate-950">
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('Affiliate Article Licensing')}</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="License affiliate article bundles, let buyers replace links, and export WordPress-ready content."
|
||||||
|
/>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<header className="sticky top-0 z-30 border-b border-white/60 bg-[#F7F3EA]/90 backdrop-blur-xl">
|
||||||
<div
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
|
||||||
className={`flex ${
|
<Link href="/" className="flex items-center gap-3 font-black tracking-tight text-slate-950">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<span className="grid h-10 w-10 place-items-center rounded-2xl bg-[#12332F] text-white shadow-lg shadow-emerald-950/20">
|
||||||
} min-h-screen w-full`}
|
<BaseIcon path={mdiFileSwapOutline} size="22" />
|
||||||
>
|
</span>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
LinkLift Studio
|
||||||
? imageBlock(illustrationImage)
|
</Link>
|
||||||
: null}
|
<nav className="flex items-center gap-3 text-sm font-semibold">
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
<Link href="#workflow" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||||
? videoBlock(illustrationVideo)
|
Workflow
|
||||||
: null}
|
</Link>
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
<Link href="#exports" className="hidden text-slate-600 transition hover:text-slate-950 sm:inline">
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
Exports
|
||||||
<CardBoxComponentTitle title="Welcome to your Affiliate Article Licensing app!"/>
|
</Link>
|
||||||
|
<BaseButton href="/login" label="Login / Admin" color="info" roundedFull />
|
||||||
<div className="space-y-3">
|
</nav>
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<BaseButtons>
|
<main>
|
||||||
<BaseButton
|
<section className="relative overflow-hidden px-6 py-16 sm:py-24">
|
||||||
href='/login'
|
<div className="absolute left-1/2 top-10 h-96 w-96 -translate-x-1/2 rounded-full bg-[#1DBA8D]/20 blur-3xl" />
|
||||||
label='Login'
|
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
color='info'
|
<div className="relative z-10">
|
||||||
className='w-full'
|
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-[#12332F]/10 bg-white/70 px-4 py-2 text-sm font-bold text-[#12332F] shadow-sm">
|
||||||
/>
|
<BaseIcon path={mdiLinkVariant} size="18" />
|
||||||
|
Affiliate content bundles for marketers
|
||||||
</BaseButtons>
|
</div>
|
||||||
</CardBox>
|
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 sm:text-6xl lg:text-7xl">
|
||||||
|
Sell article bundles that buyers can personalize in minutes.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-650 text-slate-700">
|
||||||
|
Upload WordPress-ready articles once. Buyers log in, replace your affiliate links with theirs, and download a file they can import or publish fast.
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<BaseButton href="/login" label="Open buyer portal" color="info" roundedFull className="shadow-xl shadow-blue-900/20" />
|
||||||
|
<BaseButton href="/login" label="Admin interface" color="contrast" outline roundedFull />
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 grid max-w-xl grid-cols-3 gap-3 text-center">
|
||||||
|
{[
|
||||||
|
['3', 'export formats'],
|
||||||
|
['2 min', 'buyer setup'],
|
||||||
|
['0', 'plugin required'],
|
||||||
|
].map(([value, label]) => (
|
||||||
|
<div key={label} className="rounded-3xl border border-white/70 bg-white/70 p-4 shadow-sm">
|
||||||
|
<div className="text-2xl font-black text-[#12332F]">{value}</div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className="relative z-10 rounded-[2rem] border border-white/80 bg-white p-4 shadow-2xl shadow-emerald-950/10">
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
<div className="rounded-[1.5rem] bg-[#12332F] p-6 text-white">
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold uppercase tracking-[0.25em] text-[#B7F8DA]">Live MVP</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-black">Article Export Studio</h2>
|
||||||
|
</div>
|
||||||
|
<span className="rounded-full bg-[#F4A33D] px-3 py-1 text-xs font-black text-[#12332F]">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-3xl bg-white/10 p-4">
|
||||||
|
<div className="h-3 w-2/3 rounded-full bg-white/80" />
|
||||||
|
<div className="h-3 w-full rounded-full bg-white/30" />
|
||||||
|
<div className="h-3 w-5/6 rounded-full bg-white/30" />
|
||||||
|
<div className="mt-5 rounded-2xl border border-[#B7F8DA]/30 bg-[#B7F8DA]/10 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-[#B7F8DA]">
|
||||||
|
<BaseIcon path={mdiCheckCircleOutline} size="18" />
|
||||||
|
2 affiliate links detected
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-xs text-white/80">
|
||||||
|
<div className="rounded-xl bg-black/20 p-3">amazon.com/... → your-store-id</div>
|
||||||
|
<div className="rounded-xl bg-black/20 p-3">partner.example/... → your-campaign</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
{exportOptions.map((format) => (
|
||||||
|
<div key={format} className="rounded-2xl bg-white px-3 py-3 text-center text-xs font-black text-[#12332F]">
|
||||||
|
{format}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="workflow" className="px-6 py-16">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className="grid gap-6 lg:grid-cols-3">
|
||||||
|
{workflowSteps.map((step, index) => (
|
||||||
|
<div key={step} className="rounded-[2rem] border border-white/70 bg-white/80 p-6 shadow-sm">
|
||||||
|
<div className="mb-5 grid h-12 w-12 place-items-center rounded-2xl bg-[#12332F] text-lg font-black text-white">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold leading-7 text-slate-800">{step}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="exports" className="bg-[#12332F] px-6 py-16 text-white">
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col items-start justify-between gap-8 lg:flex-row lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold uppercase tracking-[0.25em] text-[#B7F8DA]">First workflow included</p>
|
||||||
|
<h2 className="mt-3 max-w-3xl text-4xl font-black tracking-tight">Customize links, preview the buyer version, and download WordPress XML, HTML, or Markdown.</h2>
|
||||||
|
</div>
|
||||||
|
<BaseButton href="/login" label="Start after login" icon={mdiArrowRight} color="warning" roundedFull />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="flex flex-col items-center justify-between gap-4 bg-slate-950 px-6 py-8 text-sm text-white sm:flex-row">
|
||||||
|
<p>© 2026 LinkLift Studio. Built for affiliate article licensing.</p>
|
||||||
|
<div className="flex gap-5">
|
||||||
|
<Link href="/privacy-policy/" className="text-white/70 hover:text-white">
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/login" className="inline-flex items-center gap-2 font-bold text-[#B7F8DA] hover:text-white">
|
||||||
|
<BaseIcon path={mdiDownloadBoxOutline} size="16" />
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +161,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user