updated general project settings

This commit is contained in:
Dmitri 2026-03-31 08:56:49 +04:00
parent 024c04e05a
commit 3d06d927cf
26 changed files with 293 additions and 576 deletions

View File

@ -97,7 +97,8 @@ After seeding, login with credentials configured in `backend/.env`:
└── docker/ # Docker Compose setup
├── docker-compose.yml
├── start-backend.sh
└── wait-for-it.sh
├── wait-for-it.sh
└── README.md # Docker documentation
```
## Key Workflows
@ -225,7 +226,7 @@ EMAIL_PASS=...
### Frontend (`frontend/.env.local`)
```env
NEXT_PUBLIC_BACK_API=http://localhost:8080
NEXT_PUBLIC_BACK_API=http://localhost:8080/api
```
## Common Commands

View File

@ -85,28 +85,33 @@ backend/src/
├── helpers.js # Utility functions (wrapAsync)
├── auth/ # Passport.js authentication strategies
│ └── passport.js # JWT, Google, Microsoft strategies
│ └── auth.js # JWT, Google, Microsoft strategies
├── db/
│ ├── models/ # Sequelize model definitions
│ ├── db.config.js # Database connection config (per environment)
│ ├── models/ # Sequelize model definitions (16 models)
│ ├── api/ # Database access layer (CRUD per model)
│ ├── migrations/ # Database migrations
│ └── seeders/ # Seed data (admin users, permissions, roles)
├── routes/ # Express route handlers
├── routes/ # Express route handlers (22 routes)
│ ├── auth.js # Authentication endpoints
│ ├── projects.js # Project CRUD + publishing
│ ├── projects.js # Project CRUD
│ ├── tour_pages.js # Tour page management
│ ├── assets.js # Asset management
│ ├── file.js # File upload/download, presigned URLs
│ ├── publish.js # Publishing workflow
│ ├── search.js # Global search
│ └── ... # Other entity routes
├── services/ # Business logic layer
├── services/ # Business logic layer (21 services)
│ ├── auth.js # Auth service (JWT, OAuth)
│ ├── publish.js # Publishing workflow logic
│ ├── file.js # File storage abstraction
│ ├── search.js # Global search service
│ ├── email/ # Email templates and sending
│ └── ... # Other services
│ ├── notifications/ # Error classes and i18n messages
│ └── ... # Other entity services
├── middlewares/
│ ├── check-permissions.js # RBAC permission checking
@ -120,8 +125,10 @@ backend/src/
│ └── service.factory.js # Generate service classes
└── utils/
├── env-validation.js # Environment variable validation
└── ...
├── env-validation.js # Environment variable validation (Joi)
├── errors.js # Custom error classes
├── logger.js # Pino logger configuration
└── index.js # Utils barrel export
```
## Database Setup
@ -196,6 +203,10 @@ DELETE /api/{entity}/:id # Soft delete record
| `element_type_defaults` | Global element default settings |
| `project_element_defaults` | Project-specific element settings |
| `project_audio_tracks` | Background audio for projects |
| `publish_events` | Publishing history and status tracking |
| `pwa_caches` | PWA cache manifests for offline support |
| `presigned_url_requests` | S3 presigned URL request tracking |
| `access_logs` | User access audit trail |
| `users` | User accounts |
| `roles` | User roles |
| `permissions` | Granular permissions |

View File

@ -23,9 +23,6 @@ class ProjectsDBApi extends GenericDBApi {
'logo_url',
'favicon_url',
'og_image_url',
'theme_config_json',
'custom_css_json',
'cdn_base_url',
];
}
@ -44,7 +41,6 @@ class ProjectsDBApi extends GenericDBApi {
'slug',
'description',
'logo_url',
'cdn_base_url',
'createdAt',
];
}
@ -66,9 +62,6 @@ class ProjectsDBApi extends GenericDBApi {
logo_url: data.logo_url || null,
favicon_url: data.favicon_url || null,
og_image_url: data.og_image_url || null,
theme_config_json: data.theme_config_json || null,
custom_css_json: data.custom_css_json || null,
cdn_base_url: data.cdn_base_url || null,
};
}
@ -95,7 +88,8 @@ class ProjectsDBApi extends GenericDBApi {
const queryWhere = { ...where };
// Runtime access: filter by project slug
if (runtimeProjectSlug) {
// Skip if finding by ID (unambiguous lookup)
if (runtimeProjectSlug && !where.id) {
queryWhere.slug = runtimeProjectSlug;
}

View File

@ -0,0 +1,25 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, _Sequelize) {
await queryInterface.removeColumn('projects', 'theme_config_json');
await queryInterface.removeColumn('projects', 'custom_css_json');
await queryInterface.removeColumn('projects', 'cdn_base_url');
},
async down(queryInterface, Sequelize) {
await queryInterface.addColumn('projects', 'theme_config_json', {
type: Sequelize.JSON,
allowNull: true,
});
await queryInterface.addColumn('projects', 'custom_css_json', {
type: Sequelize.JSON,
allowNull: true,
});
await queryInterface.addColumn('projects', 'cdn_base_url', {
type: Sequelize.TEXT,
allowNull: true,
});
},
};

View File

@ -53,18 +53,6 @@ module.exports = function (sequelize, DataTypes) {
type: DataTypes.TEXT,
},
theme_config_json: {
type: DataTypes.JSON,
},
custom_css_json: {
type: DataTypes.JSON,
},
cdn_base_url: {
type: DataTypes.TEXT,
},
importHash: {
type: DataTypes.STRING(255),
allowNull: true,

View File

@ -24,76 +24,32 @@ const AccessLogs = db.access_logs;
const ProjectsData = [
{
name: 'Cardiff Arena Tour',
slug: 'cardiff-arena',
description: 'Interactive arena tour for visitors and event planners.',
logo_url: 'https://cdn.platform.com/cardiff/logo.png',
favicon_url: 'https://cdn.platform.com/cardiff/favicon.ico',
og_image_url: 'https://cdn.platform.com/cardiff/og.jpg',
theme_config_json:
'{colors:{primary:#0EA5E9,background:#0B1220},fonts:{base:Inter}}',
custom_css_json: '{buttons:{radius:14px}}',
cdn_base_url: 'https://cdn.platform.com/cardiff',
is_deleted: true,
deleted_at_time: new Date('2026-01-01T00:00:00Z'),
},
{
name: 'Riverside Park Walkthrough',
slug: 'riverside-park',
description: 'Offline-ready guided walkthrough for the city park.',
logo_url: 'https://cdn.platform.com/riverside/logo.png',
favicon_url: 'https://cdn.platform.com/riverside/favicon.ico',
og_image_url: 'https://cdn.platform.com/riverside/og.jpg',
theme_config_json:
'{colors:{primary:#22C55E,background:#FFFFFF},fonts:{base:Manrope}}',
custom_css_json: '{header:{shadow:md}}',
cdn_base_url: 'https://cdn.platform.com/riverside',
is_deleted: false,
deleted_at_time: new Date('2026-01-01T00:00:00Z'),
},
{
name: 'Mall Central Experience',
slug: 'mall-central',
description: 'Retail complex presentation with navigation and galleries.',
logo_url: 'https://cdn.platform.com/mall/logo.png',
favicon_url: 'https://cdn.platform.com/mall/favicon.ico',
og_image_url: 'https://cdn.platform.com/mall/og.jpg',
theme_config_json:
'{colors:{primary:#A855F7,background:#0F172A},fonts:{base:Poppins}}',
custom_css_json: '{cards:{border:1px solid rgba(255,255,255,0.12)}}',
cdn_base_url: 'https://cdn.platform.com/mall',
is_deleted: false,
deleted_at_time: new Date('2026-01-01T00:00:00Z'),
},
];

View File

@ -9,9 +9,6 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'logo_url',
'favicon_url',
'og_image_url',
'theme_config_json',
'custom_css_json',
'cdn_base_url',
],
tour_pages: [
'id',

View File

@ -38,19 +38,10 @@ router.use(checkCrudPermissions('projects'));
* og_image_url:
* type: string
* default: og_image_url
* theme_config_json:
* type: string
* default: theme_config_json
* custom_css_json:
* type: string
* default: custom_css_json
* cdn_base_url:
* type: string
* default: cdn_base_url
*
*
*/
/**
@ -357,9 +348,6 @@ router.get(
'logo_url',
'favicon_url',
'og_image_url',
'theme_config_json',
'custom_css_json',
'cdn_base_url',
];
const opts = { fields };
try {

View File

@ -98,9 +98,6 @@ module.exports = class ProjectsService {
logo_url: sourceProject.logo_url,
favicon_url: sourceProject.favicon_url,
og_image_url: sourceProject.og_image_url,
theme_config_json: sourceProject.theme_config_json,
custom_css_json: sourceProject.custom_css_json,
cdn_base_url: sourceProject.cdn_base_url,
},
{
currentUser,

View File

@ -49,22 +49,11 @@ module.exports = class SearchService {
projects: [
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
'theme_config_json',
'custom_css_json',
'cdn_base_url',
],
assets: ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'],

View File

@ -95,17 +95,19 @@ frontend/src/
│ └── {entity}/ # Entity-specific slices
│ └── {entity}Slice.ts
├── hooks/ # Custom React hooks
│ ├── useFormSync.ts # Form state synchronization
│ ├── useEntityTable.ts # Data grid with CRUD
├── hooks/ # Custom React hooks (32 hooks)
│ ├── usePreloadOrchestrator.ts # Asset preloading with S3 presigned URLs
│ ├── usePageSwitch.ts # Page navigation with preloaded blob URLs
│ ├── useNeighborGraph.ts # Page navigation graph (maxDepth: 1)
│ ├── useTransitionPlayback.ts # Video transitions
│ ├── usePageNavigation.ts # Runtime page history
│ ├── useReversePlayback.ts # Reverse video playback
│ ├── useConstructorElements.ts # Manage canvas elements
│ ├── useConstructorPageActions.ts # Save, publish, create pages
│ ├── useOfflineMode.ts # PWA offline detection
│ └── ... # Other hooks
│ ├── useFormSync.ts # Form state synchronization
│ ├── useEntityTable.ts # Data grid with CRUD
│ └── ... # Other hooks (see Custom Hooks section)
├── types/ # TypeScript definitions
│ ├── constructor.ts # Tour builder types
@ -116,16 +118,28 @@ frontend/src/
│ └── ... # Other types
├── lib/ # Utility libraries
│ ├── elementStyles.ts # Element styling utilities
│ ├── imagePreDecode.ts # Image pre-decoding
│ ├── mediaDuration.ts # Video/audio duration
│ ├── assetUrl.ts # CDN URL resolution
│ ├── constructorHelpers.ts # Constructor page helpers
│ ├── elementDefaults.ts # Element default values
│ ├── elementEffects.ts # Element effect utilities
│ ├── elementStyles.ts # Element styling utilities
│ ├── extractPageLinks.ts # Extract navigation links from pages
│ ├── parseJson.ts # Safe JSON parsing
│ ├── fonts.ts # Font configuration
│ ├── imagePreDecode.ts # Image pre-decoding
│ ├── logger.ts # Client-side logging
│ ├── mediaDuration.ts # Video/audio duration
│ ├── mediaHelpers.ts # Media utilities
│ ├── navigationHelpers.ts # Navigation utilities
│ ├── parseJson.ts # Safe JSON parsing
│ ├── slugHelpers.ts # Slug generation utilities
│ ├── tourFlowHelpers.ts # Tour flow utilities
│ ├── offline/ # Offline utilities
│ │ └── StorageManager.ts # Cache API storage for assets
│ └── offlineDb/ # IndexedDB (Dexie) setup
│ │ ├── DownloadEventBus.ts # Download event handling
│ │ ├── DownloadManager.ts # Download queue management
│ │ └── StorageManager.ts # Cache API storage for assets
│ └── offlineDb/ # IndexedDB (Dexie)
│ ├── schema.ts # Dexie database schema
│ └── OfflineDbManager.ts # Offline data management
├── layouts/ # Page layouts
│ ├── Authenticated.tsx # Logged-in users layout
@ -135,6 +149,7 @@ frontend/src/
│ └── _theme.css # Tailwind theme overrides
├── config/ # Configuration files
│ ├── offline.config.ts # Offline/PWA settings
│ └── preload.config.ts # Preload priorities and settings
├── schemas/ # Validation schemas (Zod)
├── factories/ # Component/hook factories
@ -207,22 +222,64 @@ dispatch(create({ data: newProject }));
## Custom Hooks
### Runtime & Preloading Hooks
| Hook | Purpose |
|------|---------|
| `useFormSync` | Sync form state with Redux |
| `useEntityTable` | Data grid with sorting, filtering, pagination |
| `usePreloadOrchestrator` | Asset preloading with S3 presigned URLs and ready blob URLs |
| `usePageSwitch` | Page navigation using preloaded blob URLs (O(1) instant lookup) |
| `useNeighborGraph` | Build page navigation graph (maxDepth: 1) |
| `useTransitionPlayback` | Video transition playback coordination |
| `usePageNavigation` | Runtime page history management |
| `useReversePlayback` | Reverse video playback |
| `usePageDataLoader` | Load pages with environment filtering |
| `usePreloadProgress` | Track preload progress |
| `useBackgroundTransition` | Background transition effects |
### PWA & Offline Hooks
| Hook | Purpose |
|------|---------|
| `useOfflineMode` | Detect offline/online status |
| `usePWAPreload` | Preload assets for offline |
| `useStorageQuota` | Monitor IndexedDB usage |
| `useNetworkAware` | Network-aware operations |
### Constructor Hooks
| Hook | Purpose |
|------|---------|
| `useConstructorElements` | Manage canvas elements |
| `useConstructorPageActions` | Save, publish, create pages |
| `useCanvasElementDrag` | Element drag-and-drop |
| `useCanvasElapsedTime` | Track canvas elapsed time |
| `useTransitionPreview` | Preview transitions |
| `useElementEffects` | Element visual effects |
| `useIconPreload` | Preload element icons |
| `useMediaDurationProbe` | Probe media duration |
### Form & Table Hooks
| Hook | Purpose |
|------|---------|
| `useFormSync` | Sync form state with Redux |
| `useEntityTable` | Data grid with sorting, filtering, pagination |
| `useEditPageSync` | Sync edit page state |
| `useFilterItems` | Filter items in lists |
| `useCSVHandling` | CSV import/export |
### Utility Hooks
| Hook | Purpose |
|------|---------|
| `useDraggable` | Generic draggable behavior |
| `useOutsideClick` | Detect clicks outside element |
| `useDashboardCounts` | Dashboard statistics |
| `useProjectAssets` | Project asset management |
| `useDevCompilationStatus` | Dev server compilation status |
**Component-specific hooks:**
- `useElementSettingsForm` (`components/ElementSettings/`) - Element settings form state (~60 fields)
- `useElementSettingsForm` (`components/ElementSettings/`) - Element settings form state
## Element Types

File diff suppressed because one or more lines are too long

View File

@ -1,122 +0,0 @@
/**
* PWA Loading Overlay Component
*
* Displays a "We prepare a demo" overlay during first load
* with pulsating animation and progress tracking for asset preloading.
* Uses project theme colors from theme_config_json when available.
*/
import React, { useEffect, useState } from 'react';
type PWALoadingOverlayProps = {
isVisible: boolean;
progress: number; // 0-100
projectName?: string;
themeConfig?: {
primaryColor?: string;
backgroundColor?: string;
textColor?: string;
};
onComplete?: () => void;
};
const PWALoadingOverlay: React.FC<PWALoadingOverlayProps> = ({
isVisible,
progress,
projectName,
themeConfig,
onComplete,
}) => {
const [shouldRender, setShouldRender] = useState(isVisible);
// Handle fade-out animation before unmounting
useEffect(() => {
if (!isVisible && shouldRender) {
const timer = setTimeout(() => {
setShouldRender(false);
onComplete?.();
}, 500); // Match CSS transition duration
return () => clearTimeout(timer);
}
if (isVisible) {
setShouldRender(true);
}
}, [isVisible, shouldRender, onComplete]);
if (!shouldRender) return null;
const primaryColor = themeConfig?.primaryColor || '#3b82f6';
const backgroundColor = themeConfig?.backgroundColor || '#1f2937';
const textColor = themeConfig?.textColor || '#ffffff';
return (
<div
className={`fixed inset-0 z-[9999] flex flex-col items-center justify-center transition-opacity duration-500 ${
isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
style={{ backgroundColor }}
>
{/* Pulsating logo/spinner */}
<div
className='mb-8 h-20 w-20 rounded-full animate-pulse'
style={{
backgroundColor: primaryColor,
boxShadow: `0 0 60px ${primaryColor}40`,
}}
/>
{/* Project name */}
{projectName && (
<h1
className='mb-4 text-2xl font-bold tracking-wide'
style={{ color: textColor }}
>
{projectName}
</h1>
)}
{/* Loading message */}
<p
className='mb-6 text-lg animate-pulse'
style={{ color: textColor, opacity: 0.9 }}
>
We prepare a demo
</p>
{/* Progress bar */}
<div
className='w-64 h-2 rounded-full overflow-hidden'
style={{ backgroundColor: `${textColor}20` }}
>
<div
className='h-full rounded-full transition-all duration-300 ease-out'
style={{
width: `${Math.min(100, Math.max(0, progress))}%`,
backgroundColor: primaryColor,
}}
/>
</div>
{/* Progress percentage */}
<p className='mt-3 text-sm' style={{ color: textColor, opacity: 0.7 }}>
{Math.round(progress)}%
</p>
{/* Loading indicator dots */}
<div className='mt-8 flex gap-2'>
{[0, 1, 2].map((index) => (
<div
key={index}
className='h-2 w-2 rounded-full animate-bounce'
style={{
backgroundColor: primaryColor,
animationDelay: `${index * 0.15}s`,
}}
/>
))}
</div>
</div>
);
};
export default PWALoadingOverlay;

View File

@ -132,39 +132,6 @@ const CardProjects = ({
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
ThemeconfigJSON
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.theme_config_json}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
CustomCSSJSON
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.custom_css_json}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
CDNbaseURL
</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{item.cdn_base_url}
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>
Isdeleted

View File

@ -81,25 +81,6 @@ const ListProjects = ({
<p className={'line-clamp-2'}>{item.og_image_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
ThemeconfigJSON
</p>
<p className={'line-clamp-2'}>{item.theme_config_json}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>
CustomCSSJSON
</p>
<p className={'line-clamp-2'}>{item.custom_css_json}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>CDNbaseURL</p>
<p className={'line-clamp-2'}>{item.cdn_base_url}</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Isdeleted</p>
<p className={'line-clamp-2'}>

View File

@ -25,24 +25,6 @@ const PROJECTS_COLUMNS: ColumnMetadata[] = [
type: 'text',
editable: true,
},
{
field: 'theme_config_json',
headerName: 'ThemeconfigJSON',
type: 'text',
editable: true,
},
{
field: 'custom_css_json',
headerName: 'CustomCSSJSON',
type: 'text',
editable: true,
},
{
field: 'cdn_base_url',
headerName: 'CDNbaseURL',
type: 'text',
editable: true,
},
{
field: 'is_deleted',
headerName: 'Isdeleted',

View File

@ -22,9 +22,9 @@ import { OfflineToggle } from './Offline/OfflineToggle';
import RuntimeElement from './RuntimeElement';
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { usePageDataLoader } from '../hooks/usePageDataLoader';
import { useProjectAssets } from '../hooks/useProjectAssets';
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
import { usePageSwitch } from '../hooks/usePageSwitch';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
@ -37,26 +37,6 @@ import {
} from '../lib/navigationHelpers';
import type { TransitionPhase } from '../types/presentation';
/**
* Parse custom_css_json from project for font styling
*/
const parseCustomCss = (
json: string | Record<string, unknown> | null | undefined,
): { fontFamily: string; fontStretch: string } => {
const defaults = { fontFamily: '', fontStretch: '' };
if (!json) return defaults;
try {
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
return {
fontFamily: String(parsed?.fontFamily || ''),
fontStretch: String(parsed?.fontStretch || ''),
};
} catch {
return defaults;
}
};
interface RuntimePresentationProps {
projectSlug: string;
environment: 'stage' | 'production';
@ -78,6 +58,9 @@ export default function RuntimePresentation({
},
);
// Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]);
const [transitionPreview, setTransitionPreview] = useState<{
@ -408,7 +391,42 @@ export default function RuntimePresentation({
return (
<>
<Head>
<title>{getPageTitle(project?.name || 'Presentation')}</title>
<title>{project?.name || 'Presentation'}</title>
{faviconUrl && <link key="favicon" rel="icon" href={faviconUrl} />}
{ogImageUrl && (
<>
<meta key="og:image" property="og:image" content={ogImageUrl} />
<meta
key="twitter:image:src"
property="twitter:image:src"
content={ogImageUrl}
/>
</>
)}
{project?.name && (
<>
<meta key="og:title" property="og:title" content={project.name} />
<meta
key="twitter:title"
property="twitter:title"
content={project.name}
/>
</>
)}
{project?.description && (
<>
<meta
key="og:description"
property="og:description"
content={project.description}
/>
<meta
key="twitter:description"
property="twitter:description"
content={project.description}
/>
</>
)}
</Head>
<div

View File

@ -0,0 +1,95 @@
/**
* useProjectAssets Hook
*
* Preloads and resolves project assets (favicon, og_image, logo) to presigned URLs.
* Handles both storage_key (relative paths) and legacy cdn_url (full S3 URLs) formats.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import {
extractStoragePath,
queuePresignedUrls,
resolveAssetPlaybackUrl,
} from '../lib/assetUrl';
interface ProjectAssets {
faviconUrl: string | null;
ogImageUrl: string | null;
logoUrl: string | null;
isLoading: boolean;
}
interface ProjectAssetInput {
favicon_url?: string;
og_image_url?: string;
logo_url?: string;
}
/**
* Hook to preload and resolve project assets (favicon, og_image, logo).
* Extracts storage paths from URLs (handles both formats) and resolves them
* to presigned URLs for access.
*/
export function useProjectAssets(project: ProjectAssetInput | null): ProjectAssets {
const [assets, setAssets] = useState<ProjectAssets>({
faviconUrl: null,
ogImageUrl: null,
logoUrl: null,
isLoading: true,
});
// Track the last project URLs to avoid redundant processing
const lastUrlsRef = useRef<string>('');
const resolveAssets = useCallback(async () => {
if (!project) {
setAssets({
faviconUrl: null,
ogImageUrl: null,
logoUrl: null,
isLoading: false,
});
return;
}
// Extract storage paths from URLs (handles both formats)
const faviconPath = extractStoragePath(project.favicon_url || '');
const ogImagePath = extractStoragePath(project.og_image_url || '');
const logoPath = extractStoragePath(project.logo_url || '');
// Collect paths that need presigning (relative paths only)
const pathsToPresign = [faviconPath, ogImagePath, logoPath].filter(
(path) => path && !path.startsWith('http'),
);
// Queue presigned URL requests
if (pathsToPresign.length > 0) {
await queuePresignedUrls(pathsToPresign);
}
// Resolve to playback URLs (presigned if available, otherwise proxy)
setAssets({
faviconUrl: faviconPath ? resolveAssetPlaybackUrl(faviconPath) : null,
ogImageUrl: ogImagePath ? resolveAssetPlaybackUrl(ogImagePath) : null,
logoUrl: logoPath ? resolveAssetPlaybackUrl(logoPath) : null,
isLoading: false,
});
}, [project]);
useEffect(() => {
// Create a key from the URLs to detect changes
const urlsKey = `${project?.favicon_url || ''}|${project?.og_image_url || ''}|${project?.logo_url || ''}`;
// Skip if URLs haven't changed
if (urlsKey === lastUrlsRef.current) {
return;
}
lastUrlsRef.current = urlsKey;
// Reset loading state and resolve
setAssets((prev) => ({ ...prev, isLoading: true }));
resolveAssets();
}, [project?.favicon_url, project?.og_image_url, project?.logo_url, resolveAssets]);
return assets;
}

View File

@ -295,6 +295,24 @@ export const isRelativeStoragePath = (url: string): boolean => {
);
};
/**
* Extract relative storage path from a full S3 URL.
* Converts: https://bucket.s3.region.amazonaws.com/prefix/assets/projectId/file.ext
* To: assets/projectId/file.ext
* Returns original if already a relative path or not an S3 URL.
*/
export const extractStoragePath = (url: string): string => {
const normalized = url?.trim() || '';
if (!normalized) return '';
if (isRelativeStoragePath(normalized)) return normalized;
// Extract path starting from 'assets/'
const s3Match = normalized.match(
/^https?:\/\/[^/]+\.s3\.[^/]+\.amazonaws\.com\/[^/]+\/(assets\/.+)$/,
);
return s3Match ? s3Match[1] : normalized;
};
/**
* Resolves an asset path to its full playback URL.
*

View File

@ -282,19 +282,19 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
<meta name='description' content={description} />
<meta property='og:url' content={url} />
<meta property='og:site_name' content='https://flatlogic.com/' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:image' content={image} />
<meta key='og:title' property='og:title' content={title} />
<meta key='og:description' property='og:description' content={description} />
<meta key='og:image' property='og:image' content={image} />
<meta property='og:image:type' content='image/png' />
<meta property='og:image:width' content={imageWidth} />
<meta property='og:image:height' content={imageHeight} />
<meta property='twitter:card' content='summary_large_image' />
<meta property='twitter:title' content={title} />
<meta property='twitter:description' content={description} />
<meta property='twitter:image:src' content={image} />
<meta key='twitter:title' property='twitter:title' content={title} />
<meta key='twitter:description' property='twitter:description' content={description} />
<meta key='twitter:image:src' property='twitter:image:src' content={image} />
<meta property='twitter:image:width' content={imageWidth} />
<meta property='twitter:image:height' content={imageHeight} />
<link rel='icon' href='/favicon.svg' />
<link key='favicon' rel='icon' href='/favicon.svg' />
<link rel='manifest' href='/manifest.json' />
<meta name='theme-color' content='#3B82F6' />
<meta name='mobile-web-app-capable' content='yes' />

View File

@ -26,7 +26,6 @@ import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router';
import type { Project } from '../../types/entities';
import { logger } from '../../lib/logger';
import { FONT_OPTIONS, getFontByKey, getFontKeyFromValues } from '../../lib/fonts';
const initVals = {
name: '',
@ -35,107 +34,16 @@ const initVals = {
logo_url: '',
favicon_url: '',
og_image_url: '',
// Theme config fields (stored as JSON in theme_config_json)
themePrimaryColor: '',
themeBackgroundColor: '',
themeTextColor: '',
// Custom CSS fields (stored as JSON in custom_css_json)
customFontFamily: '',
customFontStretch: '',
cdn_base_url: '',
is_deleted: false,
deleted_at_time: new Date(),
};
/**
* Parse theme_config_json into individual fields
*/
const parseThemeConfig = (
json: string | Record<string, unknown> | null | undefined,
): { primaryColor: string; backgroundColor: string; textColor: string } => {
const defaults = { primaryColor: '', backgroundColor: '', textColor: '' };
if (!json) return defaults;
try {
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
return {
primaryColor: String(parsed?.primaryColor || ''),
backgroundColor: String(parsed?.backgroundColor || ''),
textColor: String(parsed?.textColor || ''),
};
} catch {
return defaults;
}
};
/**
* Parse custom_css_json into individual fields
*/
const parseCustomCss = (
json: string | Record<string, unknown> | null | undefined,
): { fontFamily: string; fontStretch: string } => {
const defaults = { fontFamily: '', fontStretch: '' };
if (!json) return defaults;
try {
const parsed = typeof json === 'string' ? JSON.parse(json) : json;
return {
fontFamily: String(parsed?.fontFamily || ''),
fontStretch: String(parsed?.fontStretch || ''),
};
} catch {
return defaults;
}
};
/**
* Build theme_config_json from individual fields
*/
const buildThemeConfigJson = (values: {
themePrimaryColor: string;
themeBackgroundColor: string;
themeTextColor: string;
}): Record<string, string> | null => {
const config: Record<string, string> = {};
if (values.themePrimaryColor.trim()) {
config.primaryColor = values.themePrimaryColor.trim();
}
if (values.themeBackgroundColor.trim()) {
config.backgroundColor = values.themeBackgroundColor.trim();
}
if (values.themeTextColor.trim()) {
config.textColor = values.themeTextColor.trim();
}
return Object.keys(config).length > 0 ? config : null;
};
/**
* Build custom_css_json from individual fields
*/
const buildCustomCssJson = (values: {
customFontFamily: string;
customFontStretch: string;
}): Record<string, string> | null => {
const config: Record<string, string> = {};
if (values.customFontFamily.trim()) {
config.fontFamily = values.customFontFamily.trim();
}
if (values.customFontStretch.trim()) {
config.fontStretch = values.customFontStretch.trim();
}
return Object.keys(config).length > 0 ? config : null;
};
const EditProjectsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const [logoAssets, setLogoAssets] = useState<
{ id: string; cdn_url: string; name: string }[]
{ id: string; cdn_url: string; storage_key?: string; name: string }[]
>([]);
const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false);
@ -207,16 +115,6 @@ const EditProjectsPage = () => {
if (typeof project === 'object' && project !== null) {
const projectData = project as unknown as Record<string, unknown>;
// Parse theme_config_json into individual fields
const themeConfig = parseThemeConfig(
projectData.theme_config_json as string | Record<string, unknown>,
);
// Parse custom_css_json into individual fields
const customCss = parseCustomCss(
projectData.custom_css_json as string | Record<string, unknown>,
);
setInitialValues({
name: String(projectData.name || ''),
slug: String(projectData.slug || ''),
@ -224,12 +122,6 @@ const EditProjectsPage = () => {
logo_url: String(projectData.logo_url || ''),
favicon_url: String(projectData.favicon_url || ''),
og_image_url: String(projectData.og_image_url || ''),
themePrimaryColor: themeConfig.primaryColor,
themeBackgroundColor: themeConfig.backgroundColor,
themeTextColor: themeConfig.textColor,
customFontFamily: customCss.fontFamily,
customFontStretch: customCss.fontStretch,
cdn_base_url: String(projectData.cdn_base_url || ''),
is_deleted: Boolean(projectData.is_deleted),
deleted_at_time: projectData.deleted_at_time
? new Date(projectData.deleted_at_time as string)
@ -239,19 +131,6 @@ const EditProjectsPage = () => {
}, [project]);
const handleSubmit = async (data: typeof initVals) => {
// Build JSON fields from individual values
const theme_config_json = buildThemeConfigJson({
themePrimaryColor: data.themePrimaryColor,
themeBackgroundColor: data.themeBackgroundColor,
themeTextColor: data.themeTextColor,
});
const custom_css_json = buildCustomCssJson({
customFontFamily: data.customFontFamily,
customFontStretch: data.customFontStretch,
});
// Prepare data for API (exclude expanded fields, include JSON)
const apiData: Partial<Project> = {
name: data.name,
slug: data.slug,
@ -259,9 +138,6 @@ const EditProjectsPage = () => {
logo_url: data.logo_url,
favicon_url: data.favicon_url,
og_image_url: data.og_image_url,
theme_config_json: theme_config_json,
custom_css_json: custom_css_json,
cdn_base_url: data.cdn_base_url,
};
await dispatch(update({ id: id as string, data: apiData }));
@ -330,13 +206,13 @@ const EditProjectsPage = () => {
: 'Select logo from Assets'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.cdn_url}>
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.logo_url &&
!logoAssets.some(
(asset) => asset.cdn_url === values.logo_url,
(asset) => (asset.storage_key || asset.cdn_url) === values.logo_url,
) && (
<option value={values.logo_url}>
{values.logo_url}
@ -363,13 +239,13 @@ const EditProjectsPage = () => {
: 'Select favicon from Assets logos'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.cdn_url}>
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.favicon_url &&
!logoAssets.some(
(asset) => asset.cdn_url === values.favicon_url,
(asset) => (asset.storage_key || asset.cdn_url) === values.favicon_url,
) && (
<option value={values.favicon_url}>
{values.favicon_url}
@ -396,13 +272,13 @@ const EditProjectsPage = () => {
: 'Select OG image from Assets logos'}
</option>
{logoAssets.map((asset) => (
<option key={asset.id} value={asset.cdn_url}>
<option key={asset.id} value={asset.storage_key || asset.cdn_url}>
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
</option>
))}
{values.og_image_url &&
!logoAssets.some(
(asset) => asset.cdn_url === values.og_image_url,
(asset) => (asset.storage_key || asset.cdn_url) === values.og_image_url,
) && (
<option value={values.og_image_url}>
{values.og_image_url}
@ -421,56 +297,6 @@ const EditProjectsPage = () => {
</a>
)}
<FormField label='Theme Primary Color'>
<Field name='themePrimaryColor' placeholder='e.g. #E7DDB5' />
</FormField>
<FormField label='Theme Background Color'>
<Field
name='themeBackgroundColor'
placeholder='e.g. #131C22'
/>
</FormField>
<FormField label='Theme Text Color'>
<Field name='themeTextColor' placeholder='e.g. #FFFFFF' />
</FormField>
<FormField label='Custom Font Family'>
<Field name='customFontFamily'>
{({ field, form }: { field: { value: string }; form: { setFieldValue: (name: string, value: string) => void; values: typeof initVals } }) => (
<select
className='w-full rounded border border-gray-300 px-3 py-2'
value={getFontKeyFromValues(form.values.customFontFamily, form.values.customFontStretch)}
onChange={(e) => {
const fontKey = e.target.value;
if (!fontKey) {
form.setFieldValue('customFontFamily', '');
form.setFieldValue('customFontStretch', '');
} else {
const font = getFontByKey(fontKey);
if (font) {
form.setFieldValue('customFontFamily', font.fontFamily);
form.setFieldValue('customFontStretch', font.fontStretch || '');
}
}
}}
>
<option value=''>Not set</option>
{FONT_OPTIONS.map((font) => (
<option key={font.key} value={font.key}>
{font.label}
</option>
))}
</select>
)}
</Field>
</FormField>
<FormField label='CDN Base URL'>
<Field name='cdn_base_url' placeholder='CDN Base URL' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />

View File

@ -32,9 +32,6 @@ const initialValues = {
logo_url: '',
favicon_url: '',
og_image_url: '',
theme_config_json: '',
custom_css_json: '',
cdn_base_url: '',
is_deleted: false,
deleted_at_time: '',
};
@ -97,26 +94,6 @@ const ProjectsNew = () => {
<Field name='og_image_url' placeholder='OG Image URL' />
</FormField>
<FormField label='Theme Config JSON' hasTextareaHeight>
<Field
name='theme_config_json'
as='textarea'
placeholder='Theme Config JSON'
/>
</FormField>
<FormField label='Custom CSS JSON' hasTextareaHeight>
<Field
name='custom_css_json'
as='textarea'
placeholder='Custom CSS JSON'
/>
</FormField>
<FormField label='CDN Base URL'>
<Field name='cdn_base_url' placeholder='CDN Base URL' />
</FormField>
<FormField label='Is Deleted' labelFor='is_deleted'>
<Field
name='is_deleted'

View File

@ -36,10 +36,6 @@ const ProjectsTablesPage = () => {
{ label: 'LogoURL', title: 'logo_url' },
{ label: 'FaviconURL', title: 'favicon_url' },
{ label: 'OGImageURL', title: 'og_image_url' },
{ label: 'ThemeconfigJSON', title: 'theme_config_json' },
{ label: 'CustomCSSJSON', title: 'custom_css_json' },
{ label: 'CDNbaseURL', title: 'cdn_base_url' },
{ label: 'Deletedat', title: 'deleted_at_time', date: true },
]);

View File

@ -26,9 +26,6 @@ interface ProjectWithRelations extends Project {
logo_url?: string;
favicon_url?: string;
og_image_url?: string;
theme_config_json?: string;
custom_css_json?: string;
cdn_base_url?: string;
is_deleted?: boolean;
deleted_at_time?: string | Date;
project_memberships_project?: Array<{
@ -189,27 +186,6 @@ const ProjectsView = () => {
<p>{project?.og_image_url}</p>
</div>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={project?.theme_config_json}
/>
</FormField>
<FormField label='Multi Text' hasTextareaHeight>
<textarea
className={'w-full'}
disabled
value={project?.custom_css_json}
/>
</FormField>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>CDNbaseURL</p>
<p>{project?.cdn_base_url}</p>
</div>
<FormField label='Isdeleted'>
<SwitchField
field={{ name: 'is_deleted', value: project?.is_deleted }}

View File

@ -41,9 +41,6 @@ export interface Project extends BaseEntity {
logo_url?: string;
favicon_url?: string;
og_image_url?: string;
theme_config_json?: string;
custom_css_json?: string;
cdn_base_url?: string;
}
// Asset entity

View File

@ -15,6 +15,9 @@ export interface RuntimeProject {
name?: string;
slug?: string;
description?: string;
logo_url?: string;
favicon_url?: string;
og_image_url?: string;
}
/**