490 lines
19 KiB
TypeScript
Executable File
490 lines
19 KiB
TypeScript
Executable File
/// <reference types="vite/client" />
|
||
import { Post, NewsArticle, ResearchPaper, BusinessStory, ServiceItemData, ClientLogo, BusinessCourse, StudentProgram, AcceleratorProject, InvestmentProject, Vacancy, GalleryItem } from './types';
|
||
|
||
/**
|
||
* Базовый URL Strapi API.
|
||
* - В dev: пустая строка — запросы идут через Vite proxy на Strapi.
|
||
* - В prod за прокси: задайте VITE_STRAPI_URL="" при сборке — запросы на тот же origin (порт 85).
|
||
* - В prod без прокси: задайте VITE_STRAPI_URL=http://... при сборке; иначе используется запасной URL.
|
||
*/
|
||
const _url = import.meta.env?.VITE_STRAPI_URL;
|
||
export const STRAPI_URL =
|
||
import.meta.env?.DEV
|
||
? ''
|
||
: typeof _url === 'string'
|
||
? _url
|
||
: 'http://192.168.88.121:1337';
|
||
const API_TOKEN = '9e4de071544acf9ca7a9bc2b01c2fdc913c1e92e930b6c04193a00cb71a27787c19fd546e4fcebee6ed34b284015506a6eb650cea99291149328f78b4020c5028337e3dcd45f50ca379944b0a4a8276bb7f9127aa816a8b35eb68a7beef54d52a0eeea84991776165347039c20fa8b447df6c4ff653a82d4684da9db9f7716db';
|
||
|
||
interface StrapiResponse<T> {
|
||
data: StrapiDataItem<T>[];
|
||
meta: {
|
||
pagination: {
|
||
page: number;
|
||
pageSize: number;
|
||
pageCount: number;
|
||
total: number;
|
||
};
|
||
};
|
||
}
|
||
|
||
interface StrapiDataItem<T_Attributes> {
|
||
id: number;
|
||
attributes: T_Attributes;
|
||
}
|
||
|
||
interface StrapiImageAttributes {
|
||
id?: number;
|
||
url: string;
|
||
name?: string;
|
||
alternativeText?: string | null;
|
||
caption?: string | null;
|
||
width?: number;
|
||
height?: number;
|
||
formats?: {
|
||
thumbnail?: StrapiImageFormat;
|
||
small?: StrapiImageFormat;
|
||
medium?: StrapiImageFormat;
|
||
large?: StrapiImageFormat;
|
||
};
|
||
hash?: string;
|
||
ext?: string;
|
||
mime?: string;
|
||
size?: number;
|
||
provider?: string;
|
||
provider_metadata?: any;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
publishedAt?: string;
|
||
}
|
||
|
||
interface StrapiImageFormat {
|
||
name: string;
|
||
hash: string;
|
||
ext: string;
|
||
mime: string;
|
||
path: string | null;
|
||
width: number;
|
||
height: number;
|
||
size: number;
|
||
sizeInBytes?: number;
|
||
url: string;
|
||
}
|
||
|
||
interface StrapiMediaPopulated {
|
||
data: {
|
||
id: number;
|
||
attributes: StrapiImageAttributes;
|
||
} | null;
|
||
}
|
||
|
||
interface StrapiMultipleMediaPopulated {
|
||
data: {
|
||
id: number;
|
||
attributes: StrapiImageAttributes;
|
||
}[] | null;
|
||
}
|
||
|
||
|
||
const getFullImageUrl = (mediaFieldData?: StrapiImageAttributes | StrapiMediaPopulated): string => {
|
||
const placeholderBase = 'https://picsum.photos/seed/';
|
||
const placeholderFallback = `${placeholderBase}placeholder-fallback/600/400`;
|
||
|
||
if (!mediaFieldData) {
|
||
console.warn('getFullImageUrl called with undefined mediaFieldData');
|
||
return placeholderFallback;
|
||
}
|
||
|
||
// Handle case where mediaFieldData is already attributes
|
||
if ('url' in mediaFieldData && typeof mediaFieldData.url === 'string' && !('data' in mediaFieldData)) {
|
||
if (mediaFieldData.url.startsWith('http://') || mediaFieldData.url.startsWith('https://')) {
|
||
return mediaFieldData.url;
|
||
}
|
||
return `${STRAPI_URL}${mediaFieldData.url}`;
|
||
}
|
||
|
||
// Handle case where mediaFieldData has a 'data' property (populated media field)
|
||
if ('data' in mediaFieldData && mediaFieldData.data?.attributes?.url) {
|
||
const url = mediaFieldData.data.attributes.url;
|
||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||
return url;
|
||
}
|
||
return `${STRAPI_URL}${url}`;
|
||
}
|
||
|
||
console.warn('getFullImageUrl could not extract URL from mediaFieldData:', mediaFieldData);
|
||
return `${placeholderBase}no-url-extracted/600/400`;
|
||
};
|
||
|
||
// Updated rich text renderer to allow raw HTML
|
||
const transformRichTextToString = (nodes: any[] | undefined): string => {
|
||
if (!nodes || !Array.isArray(nodes)) {
|
||
return '';
|
||
}
|
||
|
||
// Helper to escape attributes to prevent some forms of XSS.
|
||
const escapeAttribute = (text: string) =>
|
||
(text || '').replace(/"/g, '"');
|
||
|
||
// Helper to escape HTML for code blocks intended for display.
|
||
const escapeHtmlForCodeDisplay = (text: string) =>
|
||
(text || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
const serializeNodeToHtml = (node: any): string => {
|
||
// For text nodes, we now trust the content and do not escape it.
|
||
// This allows users to write raw HTML directly in the editor.
|
||
if (node.type === 'text') {
|
||
let text = node.text || ''; // Raw text, not escaped.
|
||
if (node.bold) text = `<strong>${text}</strong>`;
|
||
if (node.italic) text = `<em>${text}</em>`;
|
||
if (node.underline) text = `<u>${text}</u>`;
|
||
if (node.strikethrough) text = `<s>${text}</s>`;
|
||
if (node.code) text = `<code>${text}</code>`;
|
||
return text;
|
||
}
|
||
|
||
const childrenHtml = node.children?.map(serializeNodeToHtml).join('') || '';
|
||
|
||
switch (node.type) {
|
||
case 'heading':
|
||
return `<h${node.level || 1}>${childrenHtml}</h${node.level || 1}>`;
|
||
case 'paragraph':
|
||
return `<p>${childrenHtml || ''}</p>`;
|
||
case 'quote':
|
||
return `<blockquote>${childrenHtml}</blockquote>`;
|
||
case 'list':
|
||
const listTag = node.format === 'ordered' ? 'ol' : 'ul';
|
||
return `<${listTag}>${childrenHtml}</${listTag}>`;
|
||
case 'list-item':
|
||
return `<li>${childrenHtml}</li>`;
|
||
case 'link':
|
||
return `<a href="${escapeAttribute(node.url)}" target="_blank" rel="noopener noreferrer">${childrenHtml}</a>`;
|
||
case 'image':
|
||
if (!node.image?.url) return '';
|
||
const imageUrl = getFullImageUrl(node.image);
|
||
const altText = node.image.alternativeText || '';
|
||
const caption = node.image.caption ? `<figcaption>${node.image.caption}</figcaption>` : ''; // Caption is not escaped
|
||
return `<figure><img src="${imageUrl}" alt="${escapeAttribute(altText)}" loading="lazy"/>${caption}</figure>`;
|
||
case 'code':
|
||
// For code blocks, we explicitly escape the content for display.
|
||
const codeContent = node.children?.[0]?.text || '';
|
||
return `<pre><code class="language-${node.language || ''}">${escapeHtmlForCodeDisplay(codeContent)}</code></pre>`;
|
||
default:
|
||
// For any other block types that just wrap children
|
||
return childrenHtml;
|
||
}
|
||
};
|
||
|
||
return nodes.map(serializeNodeToHtml).join('');
|
||
};
|
||
|
||
|
||
const transformRichTextToListArray = (richTextField: any[] | undefined): string[] => {
|
||
if (!richTextField || !Array.isArray(richTextField)) {
|
||
return [];
|
||
}
|
||
const result: string[] = [];
|
||
richTextField.forEach(block => {
|
||
if (block.type === 'paragraph' && Array.isArray(block.children)) {
|
||
const paragraphText = block.children
|
||
.filter((child: any) => child.type === 'text' && typeof child.text === 'string')
|
||
.map((child: any) => child.text)
|
||
.join('');
|
||
if (paragraphText) {
|
||
paragraphText.split('\n').forEach(line => {
|
||
const trimmedLine = line.trim();
|
||
if (trimmedLine) result.push(trimmedLine);
|
||
});
|
||
}
|
||
} else if (block.type === 'list' && Array.isArray(block.children)) {
|
||
block.children.forEach((listItem: any) => {
|
||
if (listItem.type === 'list-item' && Array.isArray(listItem.children)) {
|
||
const listItemText = listItem.children
|
||
.filter((child: any) => child.type === 'text' && typeof child.text === 'string')
|
||
.map((child: any) => child.text)
|
||
.join('')
|
||
.trim();
|
||
if (listItemText) result.push(listItemText);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
return result;
|
||
};
|
||
|
||
const transformGalleryItems = (galleryData: StrapiMultipleMediaPopulated | undefined): GalleryItem[] => {
|
||
if (!galleryData || !Array.isArray(galleryData.data)) {
|
||
return [];
|
||
}
|
||
return galleryData.data.map((mediaItem: StrapiDataItem<StrapiImageAttributes>) => {
|
||
const attributes = mediaItem.attributes;
|
||
const type = attributes.mime && attributes.mime.startsWith('video') ? 'video' : 'image';
|
||
return {
|
||
id: mediaItem.id.toString(),
|
||
type: type,
|
||
url: getFullImageUrl(attributes),
|
||
altText: attributes.alternativeText || undefined,
|
||
caption: attributes.caption || undefined,
|
||
};
|
||
});
|
||
};
|
||
|
||
|
||
const transformPost = (item: any): Post => {
|
||
return {
|
||
id: item.id.toString(),
|
||
title: item.title || 'Untitled Post',
|
||
category: item.category || 'Uncategorized',
|
||
date: item.date || (item.publishedAt ? new Date(item.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) : undefined),
|
||
publishedAt: item.publishedAt,
|
||
readTime: item.readTime,
|
||
imageUrl: getFullImageUrl(item.image),
|
||
videoUrl: item.videoUrl,
|
||
isFeatured: item.isFeatured || false,
|
||
description: item.description,
|
||
fullContent: Array.isArray(item.fullContent) ? transformRichTextToString(item.fullContent) : (item.fullContent || ''),
|
||
gallery: transformGalleryItems(item.gallery as StrapiMultipleMediaPopulated | undefined),
|
||
href: item.slug || `post-${item.id}`,
|
||
};
|
||
};
|
||
|
||
const transformNewsArticle = (item: any): NewsArticle => {
|
||
return {
|
||
id: item.id.toString(),
|
||
title: item.title || 'Untitled News Article',
|
||
category: item.category || 'General News',
|
||
date: item.date || (item.publishedAt ? new Date(item.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) : undefined),
|
||
publishedAt: item.publishedAt,
|
||
imageUrl: getFullImageUrl(item.image),
|
||
href: item.slug || `news-${item.id}`,
|
||
description: item.description,
|
||
fullContent: Array.isArray(item.fullContent) ? transformRichTextToString(item.fullContent) : (item.fullContent || ''),
|
||
gallery: transformGalleryItems(item.gallery as StrapiMultipleMediaPopulated | undefined),
|
||
};
|
||
};
|
||
|
||
const transformResearchPaper = (item: any): ResearchPaper => {
|
||
let authorsArray: string[] | undefined = undefined;
|
||
if (Array.isArray(item.authors)) {
|
||
authorsArray = item.authors.map((author: any) => {
|
||
if (typeof author === 'string') return author;
|
||
if (typeof author === 'object' && author !== null && typeof author.name === 'string') return author.name;
|
||
return 'Unknown Author';
|
||
});
|
||
} else if (typeof item.authors === 'string') {
|
||
authorsArray = [item.authors];
|
||
}
|
||
|
||
return {
|
||
id: item.id.toString(),
|
||
title: item.title || 'Untitled Research Paper',
|
||
category: item.category || 'Research',
|
||
date: item.date || (item.publishedAt ? new Date(item.publishedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }) : undefined),
|
||
publishedAt: item.publishedAt,
|
||
imageUrl: getFullImageUrl(item.image),
|
||
href: item.slug || `research-${item.id}`,
|
||
authors: authorsArray,
|
||
abstract: item.abstract,
|
||
fullContent: Array.isArray(item.fullContent) ? transformRichTextToString(item.fullContent) : (item.fullContent || ''),
|
||
gallery: transformGalleryItems(item.gallery as StrapiMultipleMediaPopulated | undefined),
|
||
};
|
||
};
|
||
|
||
const transformBusinessStory = (item: any): BusinessStory => {
|
||
return {
|
||
id: item.id.toString(),
|
||
title: item.title || 'Untitled Business Story',
|
||
category: item.category || 'Case Study',
|
||
imageUrl: getFullImageUrl(item.image),
|
||
href: item.slug || `biz-${item.id}`,
|
||
description: item.description,
|
||
fullContent: Array.isArray(item.fullContent) ? transformRichTextToString(item.fullContent) : (item.fullContent || ''),
|
||
gallery: transformGalleryItems(item.gallery as StrapiMultipleMediaPopulated | undefined),
|
||
};
|
||
};
|
||
|
||
const transformServiceItem = (item: any): ServiceItemData => {
|
||
return {
|
||
icon: item.icon || 'CpuChipIcon',
|
||
title: item.title || 'Untitled Service',
|
||
description: item.description || '',
|
||
};
|
||
};
|
||
|
||
const transformClientLogo = (item: any): ClientLogo => {
|
||
return {
|
||
id: item.id.toString(),
|
||
name: item.name || 'Unnamed Client',
|
||
imageUrl: getFullImageUrl(item.image),
|
||
};
|
||
};
|
||
|
||
const transformBusinessCourse = (item: any): BusinessCourse => {
|
||
return {
|
||
icon: item.icon || 'AcademicCapIcon',
|
||
title: item.title || 'Untitled Business Course',
|
||
description: item.description || '',
|
||
};
|
||
};
|
||
|
||
const transformStudentProgram = (item: any): StudentProgram => {
|
||
return {
|
||
icon: item.icon || 'AcademicCapIcon',
|
||
title: item.title || 'Untitled Student Program',
|
||
targetAudience: item.targetAudience || '',
|
||
description: item.description || '',
|
||
};
|
||
};
|
||
|
||
const transformAcceleratorProject = (item: any): AcceleratorProject => {
|
||
return {
|
||
id: item.id.toString(),
|
||
name: item.name || 'Untitled Accelerator Project',
|
||
tagline: item.tagline || '',
|
||
description: Array.isArray(item.description_rt) ? transformRichTextToString(item.description_rt) : (typeof item.description === 'string' ? item.description : ''),
|
||
imageUrl: getFullImageUrl(item.image),
|
||
category: item.category || 'General AI',
|
||
websiteUrl: item.websiteUrl,
|
||
statuspro: item.statuspro || 'Active',
|
||
};
|
||
};
|
||
|
||
const transformInvestmentProject = (item: any): InvestmentProject => {
|
||
return {
|
||
id: item.id.toString(),
|
||
name: item.name || 'Untitled Investment Project',
|
||
industry: item.industry || 'AI Sector',
|
||
problem: Array.isArray(item.problem_rt) ? transformRichTextToString(item.problem_rt) : (typeof item.problem === 'string' ? item.problem : ''),
|
||
solution: Array.isArray(item.solution_rt) ? transformRichTextToString(item.solution_rt) : (typeof item.solution === 'string' ? item.solution : ''),
|
||
teamSize: item.teamSize || 1,
|
||
fundingSought: item.fundingSought || 'N/A',
|
||
imageUrl: getFullImageUrl(item.image),
|
||
contactEmail: item.contactEmail,
|
||
};
|
||
};
|
||
|
||
const transformVacancy = (item: any): Vacancy => {
|
||
return {
|
||
id: item.id.toString(),
|
||
title: item.title || 'Untitled Vacancy',
|
||
department: item.department || 'N/A',
|
||
location: item.location || 'N/A',
|
||
type: item.jobType || 'Full-time',
|
||
description: item.shortDescription || '',
|
||
fullDescription: transformRichTextToString(item.fullDescription_rt),
|
||
responsibilities: transformRichTextToListArray(item.responsibilities_rt),
|
||
qualifications: transformRichTextToListArray(item.qualifications_rt),
|
||
offer: transformRichTextToListArray(item.offer_rt),
|
||
href: item.slug || item.id.toString(),
|
||
};
|
||
};
|
||
|
||
|
||
async function fetchData<T_Transformed>(
|
||
endpoint: string,
|
||
transformFn: (item: any) => T_Transformed
|
||
): Promise<T_Transformed[]> {
|
||
try {
|
||
const response = await fetch(`${STRAPI_URL}/api/${endpoint}?populate=*`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${API_TOKEN}`,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
let errorBody = "Could not read error body.";
|
||
try { errorBody = await response.text(); } catch (e) { /* ignore */ }
|
||
console.error(`Strapi fetch error for ${endpoint}: ${response.status} ${response.statusText}. Body: ${errorBody}`);
|
||
throw new Error(`Failed to fetch ${endpoint}: ${response.status}`);
|
||
}
|
||
|
||
let jsonResponse;
|
||
try {
|
||
jsonResponse = await response.json();
|
||
} catch (e: any) {
|
||
console.error(`Error parsing JSON for ${endpoint}:`, e.message);
|
||
try {
|
||
const textResponse = await response.text();
|
||
console.error(`Raw response text for ${endpoint}: ${textResponse.substring(0, 500)}...`);
|
||
} catch { /* ignore if reading text also fails */ }
|
||
throw new Error(`Failed to parse JSON for ${endpoint}`);
|
||
}
|
||
|
||
if (!jsonResponse.data) {
|
||
console.warn(`No 'data' field in Strapi response for ${endpoint}:`, jsonResponse);
|
||
return [];
|
||
}
|
||
if (!Array.isArray(jsonResponse.data)) {
|
||
console.warn(`Data for ${endpoint} is not an array:`, jsonResponse.data);
|
||
return [];
|
||
}
|
||
|
||
const transformedData: T_Transformed[] = [];
|
||
for (const item of jsonResponse.data) {
|
||
try {
|
||
transformedData.push(transformFn(item.attributes ? { id: item.id, ...item.attributes } : item));
|
||
} catch (transformError: any) {
|
||
console.error(`Error transforming item with id ${item?.id || 'unknown'} from ${endpoint}: ${transformError.message}`, item, transformError.stack);
|
||
}
|
||
}
|
||
return transformedData;
|
||
|
||
} catch (error: any) {
|
||
console.error(`Critical error fetching or transforming data for ${endpoint}: ${error.message}`, error.stack);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export const fetchPosts = (): Promise<Post[]> => fetchData('posts', transformPost);
|
||
export const fetchNewsArticles = (): Promise<NewsArticle[]> => fetchData('news-articles', transformNewsArticle);
|
||
export const fetchResearchPapers = (): Promise<ResearchPaper[]> => fetchData('research-papers', transformResearchPaper);
|
||
export const fetchBusinessStories = (): Promise<BusinessStory[]> => fetchData('business-stories', transformBusinessStory);
|
||
export const fetchServiceItems = (): Promise<ServiceItemData[]> => fetchData('services', transformServiceItem);
|
||
export const fetchClientLogos = (): Promise<ClientLogo[]> => fetchData('client-logos', transformClientLogo);
|
||
export const fetchBusinessCourses = (): Promise<BusinessCourse[]> => fetchData('business-courses', transformBusinessCourse);
|
||
export const fetchStudentPrograms = (): Promise<StudentProgram[]> => fetchData('student-programs', transformStudentProgram);
|
||
export const fetchAcceleratorProjects = (): Promise<AcceleratorProject[]> => fetchData('accelerator-projects', transformAcceleratorProject);
|
||
export const fetchInvestmentProjects = (): Promise<InvestmentProject[]> => fetchData('investment-opportunities', transformInvestmentProject);
|
||
export const fetchVacancies = (): Promise<Vacancy[]> => fetchData('vacancies', transformVacancy);
|
||
|
||
/** Список файлов из Strapi Media Library (для страницы «О нас» и др.).
|
||
* В Strapi: Settings → API Tokens → роль токена должна иметь Plugins → Upload → find (доступ к медиа). */
|
||
export interface StrapiUploadFile {
|
||
url: string;
|
||
name?: string;
|
||
id?: number;
|
||
}
|
||
|
||
function parseUploadItem(item: any): StrapiUploadFile | null {
|
||
const attrs = item?.attributes ?? item;
|
||
const url = typeof attrs?.url === 'string' ? attrs.url : typeof item?.url === 'string' ? item.url : '';
|
||
if (!url) return null;
|
||
const name = attrs?.name ?? attrs?.fileName ?? item?.name ?? '';
|
||
return { id: item?.id ?? attrs?.id, url, name };
|
||
}
|
||
|
||
/** Папки в Content API недоступны — запрос без фильтра folder. */
|
||
export async function fetchUploadFiles(): Promise<StrapiUploadFile[]> {
|
||
try {
|
||
const response = await fetch(`${STRAPI_URL}/api/upload/files`, {
|
||
headers: { Authorization: `Bearer ${API_TOKEN}` },
|
||
});
|
||
if (!response.ok) {
|
||
console.warn('fetchUploadFiles:', response.status, await response.text());
|
||
return [];
|
||
}
|
||
const json = await response.json();
|
||
const list = Array.isArray(json) ? json : json?.data;
|
||
if (!Array.isArray(list)) return [];
|
||
const files = list.map((item: any) => parseUploadItem(item)).filter(Boolean) as StrapiUploadFile[];
|
||
return files.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''));
|
||
} catch (e: any) {
|
||
console.warn('fetchUploadFiles error:', e?.message);
|
||
return [];
|
||
}
|
||
} |