Skip to content

Commit

Permalink
✨(frontend) add docs import from outline
Browse files Browse the repository at this point in the history
  • Loading branch information
olaurendeau committed Jan 6, 2025
1 parent e70be6f commit 03f11f4
Show file tree
Hide file tree
Showing 15 changed files with 954 additions and 9 deletions.
2 changes: 2 additions & 0 deletions src/frontend/apps/impress/.env
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_Y_PROVIDER_API_KEY=
NEXT_PUBLIC_Y_PROVIDER_API_BASE_URL=
2 changes: 2 additions & 0 deletions src/frontend/apps/impress/.env.development
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_SW_DEACTIVATED=true
NEXT_PUBLIC_Y_PROVIDER_API_KEY=yprovider-api-key
NEXT_PUBLIC_Y_PROVIDER_API_BASE_URL=http://localhost:4444/api/
1 change: 1 addition & 0 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"i18next": "24.2.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"jszip": "^3.10.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.3",
Expand Down
43 changes: 43 additions & 0 deletions src/frontend/apps/impress/src/api/fetchYProviderAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const baseYProviderUrl = () => {
return process.env.NEXT_PUBLIC_Y_PROVIDER_API_BASE_URL || 'http://localhost:4444/api/';
};

export const fetchYProvider = async (
input: string,
init?: RequestInit,
) => {
const apiUrl = `${baseYProviderUrl()}${input}`;
const apiKey = process.env.NEXT_PUBLIC_Y_PROVIDER_API_KEY || 'yprovider-api-key';

const headers = {
'Content-Type': 'application/json',
'Authorization': apiKey,
...init?.headers,
};

const response = await fetch(apiUrl, {
...init,
headers,
});

return response;
};

interface ConversionResponse {
content: string;
}

export const convertMarkdownToY = async (content: string): Promise<string> => {
const response = await fetchYProvider('convert-markdown', {
method: 'POST',
body: JSON.stringify({ content }),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to convert markdown');
}

const data = (await response.json()) as ConversionResponse;
return data.content;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';

import { Box } from '@/components';
import { Box, StyledLink } from '@/components';
import { useCreateDoc, useTrans } from '@/features/docs/doc-management/';
import { useResponsiveStore } from '@/stores';

Expand All @@ -28,10 +28,17 @@ export const DocsGridContainer = () => {
return (
<Box $overflow="auto">
<Box
$direction="row"
$align="flex-end"
$justify="center"
$justify="flex-end"
$gap="10px"
$margin={isMobile ? 'small' : 'big'}
>
<StyledLink href="/import">
<Button color="secondary">
{t('Import documents')}
</Button>
</StyledLink>
<Button onClick={handleCreateDoc}>{t('Create a new document')}</Button>
</Box>
<DocsGrid />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { Button, Select } from "@openfun/cunningham-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchAPI } from "@/api";
import { Box } from "@/components";
import styled from 'styled-components';
import { useRouter } from 'next/router';
import { DocToImport } from "../types";
import { OutlineImport } from "./OutlineImport";
import { PreviewDocsImport } from "./PreviewDocsImport";


const ImportContainer = styled.div`
width: 100%;
max-width: 1000px;
margin: 0 auto;
`;

type Source = 'outline';

type ImportState = 'idle' | 'importing' | 'completed';

export const DocsImport = () => {
const { t } = useTranslation();
const router = useRouter();
const [source, setSource] = useState<Source>('outline');
const [extractedDocs, setExtractedDocs] = useState<DocToImport[]>([]);
const [importState, setImportState] = useState<ImportState>('idle');

const updateDocState = (
docs: DocToImport[],
updatedDoc: DocToImport,
parentPath: number[] = []
): DocToImport[] => {
return docs.map((doc, index) => {
if (parentPath[0] === index) {
if (parentPath.length === 1) {
return updatedDoc;
}
return {
...doc,
children: updateDocState(doc.children || [], updatedDoc, parentPath.slice(1))
};
}
return doc;
});
};

const importDocument = async (
doc: DocToImport,
parentId?: string,
parentPath: number[] = []
): Promise<DocToImport> => {
setExtractedDocs(prev =>
updateDocState(
prev,
{ ...doc, state: 'importing' as const },
parentPath
)
);

try {
const response = await fetchAPI('documents/', {
method: 'POST',
body: JSON.stringify({
title: doc.doc.title,
content: doc.doc.content,
parent: parentId
}),
});

if (!response.ok) {
throw new Error(await response.text());
}

const { id } = await response.json();

const successDoc = {
...doc,
state: 'success' as const,
doc: { ...doc.doc, id }
};

setExtractedDocs(prev =>
updateDocState(prev, successDoc, parentPath)
);

if (doc.children?.length) {
const processedChildren = [];
for (let i = 0; i < doc.children.length; i++) {
const childDoc = await importDocument(doc.children[i], id, [...parentPath, i]);
processedChildren.push(childDoc);
}
successDoc.children = processedChildren;
}

return successDoc;
} catch (error) {
const failedDoc = {
...doc,
state: 'error' as const,
error: error instanceof Error ? error.message : 'Unknown error',
children: doc.children
};

setExtractedDocs(prev =>
updateDocState(prev, failedDoc, parentPath)
);

return failedDoc;
}
};

const handleImport = async () => {
setImportState('importing');
try {
await Promise.all(
extractedDocs.map((doc, index) => importDocument(doc, undefined, [index]))
);
setImportState('completed');
} catch (error) {
console.error('Import failed:', error);
setImportState('idle');
}
};

const handleBackToDocs = () => {
router.push('/docs');
};

const handleReset = () => {
setExtractedDocs([]);
setImportState('idle');
};

return (
<ImportContainer>
<h1 className="text-2xl font-bold mb-6">{t('Import documents')}</h1>
<Box
$margin={{ bottom: 'small' }}
aria-label={t('Import documents from')}
$gap="1.5rem"
>
<Select
clearable={false}
label={t('Source')}
options={[{
label: 'Outline',
value: 'outline',
}]}
value={source}
onChange={(options) =>
setSource(options.target.value as Source)
}
text={t('Import documents from this source')}
/>
{ source === 'outline' && <OutlineImport setExtractedDocs={setExtractedDocs} onNewUpload={handleReset}/> }
<PreviewDocsImport extractedDocs={extractedDocs} />
<Box $display="flex" $gap="medium">
<Button
onClick={handleImport}
fullWidth={true}
disabled={importState !== 'idle'}
active={importState === 'importing'}
>
{t('Import documents')}
</Button>
{importState === 'completed' && (
<Button
onClick={handleBackToDocs}
fullWidth={true}
color="secondary"
>
{t('Back to documents')}
</Button>
)}
</Box>
</Box>
</ImportContainer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { useTranslation } from 'react-i18next';

import { Box } from '@/components';
import { useResponsiveStore } from '@/stores';
import { DocsImport } from './DocsImport';

export const DocsImportContainer = () => {
const { t } = useTranslation();
const { isMobile } = useResponsiveStore();

return (
<Box $overflow="auto" $padding={isMobile ? 'small' : 'big'}>
<DocsImport />
</Box>
);
};
Loading

0 comments on commit 03f11f4

Please sign in to comment.