Files
iiEasy/strapiService.ts

490 lines
19 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// <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, '&quot;');
// Helper to escape HTML for code blocks intended for display.
const escapeHtmlForCodeDisplay = (text: string) =>
(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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 [];
}
}