Autosave: 20260318-063526

This commit is contained in:
Flatlogic Bot 2026-03-18 06:35:27 +00:00
parent 06dd524cd0
commit b4fe0dde81
16 changed files with 378 additions and 79 deletions

View File

@ -28,6 +28,11 @@ module.exports = class AssetsDBApi {
||
null
,
type: data.type
||
'general'
,
cdn_url: data.cdn_url
||
@ -123,6 +128,11 @@ module.exports = class AssetsDBApi {
asset_type: item.asset_type
||
null
,
type: item.type
||
'general'
,
cdn_url: item.cdn_url
@ -215,6 +225,9 @@ module.exports = class AssetsDBApi {
if (data.asset_type !== undefined) updatePayload.asset_type = data.asset_type;
if (data.type !== undefined) updatePayload.type = data.type;
if (data.cdn_url !== undefined) updatePayload.cdn_url = data.cdn_url;
@ -608,6 +621,13 @@ module.exports = class AssetsDBApi {
};
}
if (filter.type) {
where = {
...where,
type: filter.type,
};
}
if (filter.is_public) {
where = {
...where,

View File

@ -0,0 +1,105 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableRows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.assets') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!tableRows[0]?.regclass_name) {
await transaction.commit();
return;
}
const tableDefinition = await queryInterface.describeTable('assets', { transaction });
if (!tableDefinition.type) {
await queryInterface.addColumn(
'assets',
'type',
{
type: Sequelize.DataTypes.ENUM,
values: [
'icon',
'background_image',
'audio',
'video',
'transition',
'logo',
'favicon',
'document',
'general',
],
allowNull: false,
defaultValue: 'general',
},
{ transaction },
);
await queryInterface.sequelize.query(
`
UPDATE assets
SET type = (
CASE
WHEN name ILIKE '[ICON] %' OR name ILIKE '[IMAGE] %' THEN 'icon'
WHEN name ILIKE '[BACKGROUND] %' THEN 'background_image'
WHEN name ILIKE '[AUDIO] %' THEN 'audio'
WHEN name ILIKE '[VIDEO] %' THEN 'video'
WHEN name ILIKE '[TRANSITION] %' THEN 'transition'
WHEN name ILIKE '[LOGO] %' THEN 'logo'
WHEN asset_type = 'audio' THEN 'audio'
WHEN asset_type = 'video' THEN 'video'
ELSE 'general'
END
)::"enum_assets_type"
WHERE type = 'general';
`,
{ transaction },
);
await queryInterface.addIndex('assets', ['type'], { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
const tableRows = await queryInterface.sequelize.query(
"SELECT to_regclass('public.assets') AS regclass_name;",
{
transaction,
type: Sequelize.QueryTypes.SELECT,
},
);
if (!tableRows[0]?.regclass_name) {
await transaction.commit();
return;
}
const tableDefinition = await queryInterface.describeTable('assets', { transaction });
if (tableDefinition.type) {
await queryInterface.removeIndex('assets', ['type'], { transaction });
await queryInterface.removeColumn('assets', 'type', { transaction });
}
await transaction.commit();
} catch (error) {
await transaction.rollback();
throw error;
}
},
};

View File

@ -36,6 +36,43 @@ asset_type: {
},
type: {
type: DataTypes.ENUM,
allowNull: false,
defaultValue: "general",
values: [
"icon",
"background_image",
"audio",
"video",
"transition",
"logo",
"favicon",
"document",
"general"
],
},
cdn_url: {
type: DataTypes.TEXT,
@ -132,6 +169,7 @@ deleted_at_time: {
indexes: [
{ fields: ['projectId'] },
{ fields: ['asset_type'] },
{ fields: ['type'] },
{ fields: ['is_public'] },
{ fields: ['is_deleted'] },
{ fields: ['deletedAt'] },
@ -198,4 +236,3 @@ deleted_at_time: {
return assets;
};

View File

@ -120,6 +120,7 @@ module.exports = class ProjectsService {
{
name: sourceAsset.name,
asset_type: sourceAsset.asset_type,
type: sourceAsset.type || 'general',
cdn_url: sourceAsset.cdn_url,
storage_key: sourceAsset.storage_key,
mime_type: sourceAsset.mime_type,

View File

@ -102,13 +102,22 @@ const CardAssets = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Assettype</dt>
<dt className=' text-gray-500 dark:text-dark-600'>Asset format</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.asset_type }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Type</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.type || 'general' }
</div>
</dd>
</div>

View File

@ -64,9 +64,14 @@ const ListAssets = ({ assets, loading, onDelete, currentPage, numPages, onPageCh
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Assettype</p>
<p className={'text-xs text-gray-500 '}>Asset format</p>
<p className={'line-clamp-2'}>{ item.asset_type }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Type</p>
<p className={'line-clamp-2'}>{ item.type || 'general' }</p>
</div>
@ -189,4 +194,4 @@ const ListAssets = ({ assets, loading, onDelete, currentPage, numPages, onPageCh
)
};
export default ListAssets
export default ListAssets

View File

@ -80,7 +80,22 @@ export const loadColumns = async (
{
field: 'asset_type',
headerName: 'Assettype',
headerName: 'Asset format',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
},
{
field: 'type',
headerName: 'Type',
flex: 1,
minWidth: 120,
filterable: false,

View File

@ -16,6 +16,7 @@ import { appWithTranslation } from 'next-i18next';
import '../i18n';
import IntroGuide from '../components/IntroGuide';
import { appSteps, loginSteps, usersSteps, rolesSteps } from '../stores/introSteps';
import { logoutUser } from '../stores/authSlice';
// Initialize axios
axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API
@ -62,6 +63,31 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
};
}, []);
React.useEffect(() => {
const interceptorId = axios.interceptors.response.use(
(response) => response,
(error) => {
const status = error?.response?.status;
const requestUrl = `${error?.config?.url || ''}`;
const isLoginRequest = requestUrl.includes('/auth/signin/local') || requestUrl.includes('auth/signin/local');
if (status === 401 && !isLoginRequest) {
store.dispatch(logoutUser());
if (router.pathname !== '/login') {
router.replace('/login');
}
}
return Promise.reject(error);
},
);
return () => {
axios.interceptors.response.eject(interceptorId);
};
}, [router]);
// TODO: Remove this code in future releases
React.useEffect(() => {
const trustedOrigins = new Set<string>([window.location.origin]);

View File

@ -113,6 +113,7 @@ const EditAssets = () => {
asset_type: '',
type: 'general',
@ -612,7 +613,7 @@ const EditAssets = () => {
<FormField label="Assettype" labelFor="asset_type">
<FormField label="Asset Format" labelFor="asset_type">
<Field name="asset_type" id="asset_type" component="select">
<option value="image">image</option>
@ -625,6 +626,20 @@ const EditAssets = () => {
</Field>
</FormField>
<FormField label="Type" labelFor="type">
<Field name="type" id="type" component="select">
<option value="general">general</option>
<option value="icon">icon</option>
<option value="background_image">background_image</option>
<option value="audio">audio</option>
<option value="video">video</option>
<option value="transition">transition</option>
<option value="logo">logo</option>
<option value="favicon">favicon</option>
<option value="document">document</option>
</Field>
</FormField>

View File

@ -113,6 +113,7 @@ const EditAssetsPage = () => {
asset_type: '',
type: 'general',
@ -609,7 +610,7 @@ const EditAssetsPage = () => {
<FormField label="Assettype" labelFor="asset_type">
<FormField label="Asset Format" labelFor="asset_type">
<Field name="asset_type" id="asset_type" component="select">
<option value="image">image</option>
@ -622,6 +623,20 @@ const EditAssetsPage = () => {
</Field>
</FormField>
<FormField label="Type" labelFor="type">
<Field name="type" id="type" component="select">
<option value="general">general</option>
<option value="icon">icon</option>
<option value="background_image">background_image</option>
<option value="audio">audio</option>
<option value="video">video</option>
<option value="transition">transition</option>
<option value="logo">logo</option>
<option value="favicon">favicon</option>
<option value="document">document</option>
</Field>
</FormField>

View File

@ -23,6 +23,16 @@ type Asset = {
id: string
name: string
asset_type: 'image' | 'video' | 'audio' | 'file'
type?:
| 'icon'
| 'background_image'
| 'audio'
| 'video'
| 'transition'
| 'logo'
| 'favicon'
| 'document'
| 'general'
cdn_url?: string | null
mime_type?: string | null
}
@ -31,8 +41,9 @@ type AssetSection = {
key: 'images' | 'backgroundImages' | 'audio' | 'video' | 'transitions' | 'logo'
label: string
accept: string
assetType: 'image' | 'video' | 'audio'
tag: string
assetFormat: 'image' | 'video' | 'audio'
assetCategory: NonNullable<Asset['type']>
legacyTag: string
}
type UploadQueueItem = {
@ -44,28 +55,28 @@ type UploadQueueItem = {
}
const ASSET_SECTIONS: AssetSection[] = [
{ key: 'images', label: 'Icons', accept: 'image/*', assetType: 'image', tag: 'IMAGE' },
{ key: 'images', label: 'Icons', accept: 'image/*', assetFormat: 'image', assetCategory: 'icon', legacyTag: 'IMAGE' },
{
key: 'backgroundImages',
label: 'Background Images',
accept: 'image/*',
assetType: 'image',
tag: 'BACKGROUND',
assetFormat: 'image',
assetCategory: 'background_image',
legacyTag: 'BACKGROUND',
},
{ key: 'audio', label: 'Audio', accept: 'audio/*', assetType: 'audio', tag: 'AUDIO' },
{ key: 'video', label: 'Video', accept: 'video/*', assetType: 'video', tag: 'VIDEO' },
{ key: 'audio', label: 'Audio', accept: 'audio/*', assetFormat: 'audio', assetCategory: 'audio', legacyTag: 'AUDIO' },
{ key: 'video', label: 'Video', accept: 'video/*', assetFormat: 'video', assetCategory: 'video', legacyTag: 'VIDEO' },
{
key: 'transitions',
label: 'Transitions',
accept: 'video/*',
assetType: 'video',
tag: 'TRANSITION',
assetFormat: 'video',
assetCategory: 'transition',
legacyTag: 'TRANSITION',
},
{ key: 'logo', label: 'Logo', accept: 'image/*', assetType: 'image', tag: 'LOGO' },
{ key: 'logo', label: 'Logo', accept: 'image/*', assetFormat: 'image', assetCategory: 'logo', legacyTag: 'LOGO' },
]
const getTaggedName = (tag: string, name: string) => `[${tag}] ${name}`
const AssetsTablesPage = () => {
const router = useRouter()
const routeProjectId = useMemo(() => {
@ -218,8 +229,9 @@ const AssetsTablesPage = () => {
await axios.post('/assets', {
data: {
project: projectId,
name: getTaggedName(section.tag, file.name),
asset_type: section.assetType,
name: file.name,
asset_type: section.assetFormat,
type: section.assetCategory,
cdn_url: remoteFile.publicUrl,
storage_key: remoteFile.privateUrl,
mime_type: file.type || null,
@ -313,7 +325,10 @@ const AssetsTablesPage = () => {
const assetsBySection = useMemo(() => {
return ASSET_SECTIONS.reduce<Record<string, Asset[]>>((acc, section) => {
acc[section.key] = assets.filter((asset) => asset.name?.startsWith(`[${section.tag}] `))
acc[section.key] = assets.filter((asset) => {
if (asset.type) return asset.type === section.assetCategory
return asset.name?.startsWith(`[${section.legacyTag}] `)
})
return acc
}, {})
}, [assets])

View File

@ -72,6 +72,7 @@ const initialValues = {
asset_type: 'image',
type: 'general',
@ -369,7 +370,7 @@ const AssetsNew = () => {
<FormField label="Assettype" labelFor="asset_type">
<FormField label="Asset Format" labelFor="asset_type">
<Field name="asset_type" id="asset_type" component="select">
<option value="image">image</option>
@ -383,6 +384,20 @@ const AssetsNew = () => {
</Field>
</FormField>
<FormField label="Type" labelFor="type">
<Field name="type" id="type" component="select">
<option value="general">general</option>
<option value="icon">icon</option>
<option value="background_image">background_image</option>
<option value="audio">audio</option>
<option value="video">video</option>
<option value="transition">transition</option>
<option value="logo">logo</option>
<option value="favicon">favicon</option>
<option value="document">document</option>
</Field>
</FormField>

View File

@ -44,7 +44,8 @@ const AssetsTablesPage = () => {
{label: 'Assettype', title: 'asset_type', type: 'enum', options: ['image','video','audio','file']},
{label: 'Asset format', title: 'asset_type', type: 'enum', options: ['image','video','audio','file']},
{label: 'Type', title: 'type', type: 'enum', options: ['icon','background_image','audio','video','transition','logo','favicon','document','general']},
]);
const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_ASSETS');

View File

@ -170,9 +170,13 @@ const AssetsView = () => {
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Assettype</p>
<p className={'block font-bold mb-2'}>Asset format</p>
<p>{assets?.asset_type ?? 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Type</p>
<p>{assets?.type ?? 'No data'}</p>
</div>
@ -676,4 +680,4 @@ AssetsView.getLayout = function getLayout(page: ReactElement) {
)
}
export default AssetsView;
export default AssetsView;

View File

@ -35,6 +35,7 @@ type ProjectAsset = {
id: string;
name?: string;
asset_type?: 'image' | 'video' | 'audio' | 'file';
type?: 'icon' | 'background_image' | 'audio' | 'video' | 'transition' | 'logo' | 'favicon' | 'document' | 'general';
cdn_url?: string | null;
storage_key?: string | null;
};
@ -109,7 +110,7 @@ type TransitionPreviewState = {
title: string;
};
type EditorMenuItem = 'none' | 'background_image' | 'background_video' | 'background_audio';
type EditorMenuItem = 'none' | 'background_image' | 'background_video' | 'background_audio' | 'create_transition';
const parseJsonObject = <T,>(value?: string, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
@ -211,6 +212,7 @@ const resolveAssetPlaybackUrl = (value?: string) => {
};
const isBackgroundImageAsset = (asset: ProjectAsset) => {
if (asset.type) return asset.type === 'background_image';
const normalizedName = String(asset.name || '').toLowerCase();
if (!normalizedName) return false;
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(normalizedName);
@ -411,13 +413,19 @@ const ConstructorPage = () => {
[assets],
);
const transitionVideoAssetOptions = useMemo(() => {
const tagged = assets
const typedAssets = assets
.filter((asset) => asset.type === 'transition' && asset.asset_type === 'video' && getAssetSourceValue(asset))
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) }));
if (typedAssets.length > 0) return typedAssets;
const taggedAssets = assets
.filter(
(asset) => asset.asset_type === 'video' && getAssetSourceValue(asset) && /\[TRANSITION\]/i.test(String(asset.name || '')),
)
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) }));
if (tagged.length > 0) return tagged;
if (taggedAssets.length > 0) return taggedAssets;
return videoAssetOptions;
}, [assets, videoAssetOptions]);
@ -747,7 +755,7 @@ const ConstructorPage = () => {
environment: activePage?.environment || 'dev',
source_key: '',
name: sanitizedName,
slug: `transition-${Date.now().toString().slice(-4)}`,
slug: `transition-${createLocalId()}`,
video_url: sanitizedVideoUrl,
audio_url: '',
supports_reverse: Boolean(newTransitionSupportsReverse),
@ -1070,7 +1078,9 @@ const ConstructorPage = () => {
? 'Background video'
: selectedMenuItem === 'background_audio'
? 'Background audio'
: selectedElement?.label || 'Element editor';
: selectedMenuItem === 'create_transition'
? 'Create transition'
: selectedElement?.label || 'Element editor';
if (backgroundImageSrc) {
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
@ -1339,6 +1349,55 @@ const ConstructorPage = () => {
</div>
)}
{selectedMenuItem === 'create_transition' && (
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-600'>Create next page transition</p>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Name'
value={newTransitionName}
onChange={(event) => setNewTransitionName(event.target.value)}
/>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={newTransitionVideoUrl}
onChange={(event) => setNewTransitionVideoUrl(event.target.value)}
>
<option value=''>Transition video asset</option>
{transitionVideoAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={newTransitionSupportsReverse}
onChange={(event) => setNewTransitionSupportsReverse(event.target.checked)}
/>
Supports reverse playback
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
type='number'
min='0.2'
step='0.1'
value={newTransitionDurationSec}
onChange={(event) => setNewTransitionDurationSec(Number(event.target.value || 0.7))}
/>
<button
type='button'
className='menu-action-btn'
onClick={createTransition}
disabled={isCreatingTransition}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>{isCreatingTransition ? 'Creating Transition...' : 'Create Transition'}</span>
</button>
</div>
)}
{selectedElement && (
<div className='mb-2'>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>Label</label>
@ -1447,52 +1506,6 @@ const ConstructorPage = () => {
<BaseButton small color='lightDark' label='Preview Forward' onClick={() => openTransitionPreview('forward')} />
<BaseButton small color='lightDark' label='Preview Back' onClick={() => openTransitionPreview('back')} />
</div>
<div className='rounded border border-gray-200 p-2 space-y-2'>
<p className='text-[11px] font-semibold text-gray-600'>Create next page transition</p>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
placeholder='Name'
value={newTransitionName}
onChange={(event) => setNewTransitionName(event.target.value)}
/>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={newTransitionVideoUrl}
onChange={(event) => setNewTransitionVideoUrl(event.target.value)}
>
<option value=''>Transition video asset</option>
{transitionVideoAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
<input
type='checkbox'
checked={newTransitionSupportsReverse}
onChange={(event) => setNewTransitionSupportsReverse(event.target.checked)}
/>
Supports reverse playback
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
type='number'
min='0.2'
step='0.1'
value={newTransitionDurationSec}
onChange={(event) => setNewTransitionDurationSec(Number(event.target.value || 0.7))}
/>
<button
type='button'
className='menu-action-btn'
onClick={createTransition}
disabled={isCreatingTransition}
>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>{isCreatingTransition ? 'Creating Transition...' : 'Create Transition'}</span>
</button>
</div>
</div>
)}
@ -1729,6 +1742,10 @@ const ConstructorPage = () => {
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>Add Navigation</span>
</button>
<button type='button' className='menu-action-btn' onClick={() => selectMenuItemForEdit('create_transition')}>
<BaseIcon path={mdiSwapHorizontal} size={16} />
<span>Add Transition</span>
</button>
<button type='button' className='menu-action-btn' onClick={() => addElement('gallery')}>
<BaseIcon path={mdiImageMultiple} size={16} />
<span>Add Gallery</span>

View File

@ -432,8 +432,7 @@ const EditProjectsPage = () => {
typeof asset?.cdn_url === 'string' &&
asset.cdn_url &&
asset?.asset_type === 'image' &&
typeof asset?.name === 'string' &&
asset.name.startsWith('[LOGO] '),
(asset?.type === 'logo' || (typeof asset?.name === 'string' && asset.name.startsWith('[LOGO] '))),
)
setLogoAssets(taggedLogoAssets)
} catch (error: any) {