(frontend) set up Vite-based frontend project

Chose Vite for static output efficiency, aligning with project needs.

All API interactions are currently unauthenticated. SSO support
planned soon, using ProConnect.

UX is minimalistic, and showcases the core idea.

Components introduced:
* AppProvider
* Select and TextArea Rhf inputs

API hooks introduced:
* useGeneratePDF, generates a PDF, and downloads it in the client.
* useTemplates, fetches available templates to populate Select options.
This commit is contained in:
Lebaud Antoine
2024-01-10 15:07:05 +01:00
parent 62df0524ac
commit 312a680b66
41 changed files with 6926 additions and 3 deletions

View File

@@ -236,7 +236,7 @@ class Template(BaseModel):
Generate and return a PDF document for this template around the
markdown body passed as argument.
"""
body_html = markdown.markdown(textwrap.dedent(body)) if body else ""
body_html = markdown.markdown(textwrap.dedent(body)) if body else ""
document_html = HTML(string=DjangoTemplate(self.code).render(Context({"body": body_html})))
css = CSS(
string=self.css,

View File

@@ -1,8 +1,10 @@
"""URL configuration for the core app."""
from django.urls import path
from core.views import generate_document
from core.views import generate_document, TemplatesApiView, GenerateDocumentAPIView
urlpatterns = [
path('generate-document/', generate_document, name='generate_document'),
path('api/generate-document/', GenerateDocumentAPIView.as_view(), name='generate-document'),
path('api/templates', TemplatesApiView.as_view()),
]

View File

@@ -1,6 +1,14 @@
from django.shortcuts import render, HttpResponse
from .forms import DocumentGenerationForm
from .models import Template
from rest_framework import status
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import serializers
from django.http import FileResponse
from io import BytesIO
def generate_document(request):
@@ -25,3 +33,47 @@ def generate_document(request):
return render(request, 'core/generate_document.html', {'form': form})
class DocumentGenerationSerializer(serializers.Serializer):
body = serializers.CharField(label="Markdown Body")
template_id = serializers.UUIDField(format='hex_verbose')
class GenerateDocumentAPIView(APIView):
def post(self, request):
serializer = DocumentGenerationSerializer(data=request.data)
if not serializer.is_valid():
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
template_id = serializer.validated_data['template_id']
body = serializer.validated_data['body']
try:
template = Template.objects.get(pk=template_id)
except Template.DoesNotExist:
return Response("Template not found", status=status.HTTP_404_NOT_FOUND)
pdf_content = template.generate_document(body)
response = FileResponse(BytesIO(pdf_content), content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename={template.title}.pdf'
return response
class TemplateSerializer(serializers.ModelSerializer):
class Meta:
model = Template
fields = ['id', 'title']
class TemplatesApiView(APIView):
"""Wip."""
def get(self, request, *args, **kwargs):
"""Wip."""
templates = Template.objects.all()
serializer = TemplateSerializer(templates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -270,7 +270,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])

View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,76 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}

157
src/frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,157 @@
'use client';
import Image from 'next/image'
import {Button} from "@/components/ui/button";
import React, {useEffect, useState} from "react";
import {Textarea} from "@/components/ui/textarea";
import {Input} from "@/components/ui/input";
import {Form} from "@/components/ui/form";
import * as z from "zod"
import {useForm} from "react-hook-form";
import {zodResolver} from "@hookform/resolvers/zod";
import {FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage} from "@/components/ui/form";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
import {Toaster} from "@/components/ui/sonner";
import {toast} from "sonner";
const formSchema = z.object({
body: z.string(),
template_id: z.string()
})
export default function Home() {
const [templates, setTemplates] = useState([]);
const [isFetching, setIsFetching] = useState(false);
const fetchTemplates = async () => {
const res= await fetch('http://localhost:8071/api/templates');
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error('Failed to fetch data')
}
const templates = await res.json()
setTemplates(templates);
};
useEffect( () => {
fetchTemplates();
}, [])
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
body: "",
template_id: "",
},
})
function download(blob, filename) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
// the filename you want
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
const generateDocument = async (values: any) => {
const res = await fetch('http://localhost:8071/api/generate-document/',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
})
if (!res.ok) {
// This will activate the closest `error.js` Error Boundary
throw new Error('Failed to generate document')
}
return await res.blob()
}
async function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
setIsFetching(true)
try {
const document = await generateDocument(values)
download(document, "wip.pdf")
toast("Fichier téléchargé.", {
description: "Nous avons généré votre document à partir du template sélectionné.",
})
setIsFetching(false)
} catch (e) {
setIsFetching(false)
}
}
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24 relative">
<div className="absolute left-8 top-8 flex">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6 mr-2">
<path strokeLinecap="round" strokeLinejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Zm-3 0h.008v.008H15V10.5Z" />
</svg>
<span className="font-medium">Imprint</span>
</div>
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="body"
render={({ field }) => (
<FormItem>
<FormLabel>Contenu</FormLabel>
<FormControl>
<Textarea placeholder="Coller votre code markdown." {...field} className="min-h-96" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="template_id"
render={({ field }) => (
<FormItem>
<FormLabel>Template</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un model de document." />
</SelectTrigger>
</FormControl>
<SelectContent>
{
templates?.map((template) => (
<SelectItem value={template.id} key={template.id}>{template.title}</SelectItem>
))
}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isFetching}>Générer votre PDF</Button>
</form>
</Form>
</div>
<Toaster />
</main>
)
}

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

13
src/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Imprint</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5481
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
src/frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"build-theme": "cunningham -g css -o src"
},
"dependencies": {
"@hookform/resolvers": "3.3.4",
"@openfun/cunningham-react": "2.4.0",
"@tanstack/react-query": "5.17.10",
"@tanstack/react-query-devtools": "5.17.10",
"axios": "1.6.5",
"downloadjs": "1.4.7",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "7.49.3",
"yup": "1.3.3"
},
"devDependencies": {
"@types/react": "18.2.47",
"@types/react-dom": "18.2.18",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@vitejs/plugin-react": "4.2.1",
"eslint": "8.56.0",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.5",
"sass": "1.69.7",
"typescript": "5.3.3",
"vite": "5.0.11"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

22
src/frontend/src/App.scss Normal file
View File

@@ -0,0 +1,22 @@
.c__app {
&__title {
text-align: center;
}
$gap: 1rem;
&__form {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 55rem;
&__inputs {
display: flex;
flex-direction: column;
gap: calc($gap / 2);
}
}
}

90
src/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,90 @@
import {useEffect, useMemo} from 'react'
import './App.scss'
import {FormProvider, useForm} from "react-hook-form";
import {Button} from "@openfun/cunningham-react";
import * as Yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import {TextArea, Select} from "./components";
import {useTemplates, useGeneratePDF} from "./api";
interface FormValues {
body: string;
template_id: string;
}
const FormSchema = Yup.object().shape({
body: Yup.string().required('Veuillez saisir votre contenu markdown.'),
template_id: Yup.string().required('Veuillez sélectionner un template.'),
});
export interface FormProps {
values?: FormValues;
}
function App({ values }: FormProps) {
const methods = useForm<FormValues>({
defaultValues: {
body: "",
template_id: ""
},
mode: "onChange",
reValidateMode: "onChange",
resolver: yupResolver(FormSchema),
});
useEffect(() => {
methods.reset(values);
}, [values, methods]);
const { error, data: templates, isFetching } = useTemplates()
const { mutate: generatePDF } = useGeneratePDF();
const options = useMemo(() => {
if (!templates) return [];
return templates?.map((template) => ({
label: template.title,
value: template.id
}));
}, [templates])
if (isFetching) return <div>Loading...</div>
if (error) return <div>Something went wrong...</div>
return (
<>
<h1 className="c__app__title">Imprint</h1>
<div>
<FormProvider {...methods}>
<form
className="c__app__form"
onSubmit={methods.handleSubmit((values) => generatePDF({data:values, filename:"wipp.pdf"}))}
>
<div className="c__app__form__inputs">
<TextArea
name="body"
label="Saisir votre contenu markdown"
fullWidth={true}
style={{minHeight: "24rem"}}
/>
<Select
name="template_id"
label="Sélectionner un template"
fullWidth={true}
options={options}
/>
</div>
<Button fullWidth={true}>Générer votre PDF</Button>
</form>
</FormProvider>
</div>
</>
)
}
export default App

View File

@@ -0,0 +1,2 @@
export * from "./useTemplates"
export * from "./useGeneratePDF"

View File

@@ -0,0 +1,28 @@
import {axios} from "../lib";
import {useMutation} from "@tanstack/react-query";
import download from 'downloadjs';
interface PDF {
data: {
template_id: string;
body: string;
};
filename: string;
}
const generatePDF = async ({data, filename}: PDF) => {
const response = await axios.post('generate-document/', data, {responseType: 'blob'});
const content = response.headers['content-type'];
return {data: response.data, filename, content}
}
export const useGeneratePDF = () => {
return useMutation({
mutationFn: generatePDF,
onSuccess: ({data, filename, content}) => {
download(data, filename, content)
},
});
}

View File

@@ -0,0 +1,23 @@
import {useQuery} from "@tanstack/react-query";
import {axios} from "../lib";
interface Template {
id: string;
title: string;
}
type TemplatesResponse = Array<Template>;
export const getTemplates = async () : Promise<TemplatesResponse> => {
const response = await axios.get('templates')
return response.data
}
export const useTemplates = () => {
return useQuery({
queryKey: ['templates'],
queryFn: () => getTemplates(),
staleTime: Infinity,
})
}

View File

@@ -0,0 +1,17 @@
import {QueryClientProvider} from "@tanstack/react-query";
import {CunninghamProvider} from "@openfun/cunningham-react";
import {queryClient} from "../../lib";
import {ReactQueryDevtools} from "@tanstack/react-query-devtools";
export type AppProviderProps = {
children: React.ReactNode;
};
export const AppProvider = ({children}: AppProviderProps) => (
<QueryClientProvider client={queryClient}>
<CunninghamProvider>
{children}
<ReactQueryDevtools initialIsOpen />
</CunninghamProvider>
</QueryClientProvider>
)

View File

@@ -0,0 +1,24 @@
import {Select as CSelect, SelectProps as CSelectProps} from "@openfun/cunningham-react";
import {Controller, useFormContext} from "react-hook-form";
export const Select = (props: CSelectProps & { name: string }) => {
const { control, setValue } = useFormContext();
return (
<Controller
control={control}
name={props.name}
render={({ field, fieldState }) => {
return (
<CSelect
{...props}
state={fieldState.error ? "error" : "default"}
text={fieldState.error?.message}
onBlur={field.onBlur}
onChange={(e) => setValue(field.name, e.target.value)}
value={field.value}
/>
);
}}
/>
);
};

View File

@@ -0,0 +1,26 @@
import {TextAreaProps as CTextAreaProps, TextArea as CTextArea} from "@openfun/cunningham-react";
import {Controller, useFormContext} from "react-hook-form";
export const TextArea = (props: CTextAreaProps & { name: string }) => {
const { control, setValue } = useFormContext();
return (
<Controller
control={control}
name={props.name}
render={({ field, fieldState }) => {
return (
<CTextArea
{...props}
aria-invalid={!!fieldState.error}
state={fieldState.error ? "error" : "default"}
text={fieldState.error?.message}
onBlur={field.onBlur}
onChange={(e) => setValue(field.name, e.target.value)}
value={field.value}
fullWidth={true}
/>
);
}}
/>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./TextArea"
export * from "./Select"

View File

@@ -0,0 +1,2 @@
export * from './Form'
export * from './AppProvider'

View File

@@ -0,0 +1,163 @@
:root {
--c--theme--colors--secondary-text: var(--c--theme--colors--greyscale-700);
--c--theme--colors--secondary-100: #F2F7FC;
--c--theme--colors--secondary-200: #EBF3FA;
--c--theme--colors--secondary-300: #E2EEF8;
--c--theme--colors--secondary-400: #DDEAF7;
--c--theme--colors--secondary-500: #D4E5F5;
--c--theme--colors--secondary-600: #C1D0DF;
--c--theme--colors--secondary-700: #97A3AE;
--c--theme--colors--secondary-800: #757E87;
--c--theme--colors--secondary-900: #596067;
--c--theme--colors--info-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--info-100: #EBF2FC;
--c--theme--colors--info-200: #8CB5EA;
--c--theme--colors--info-300: #5894E1;
--c--theme--colors--info-400: #377FDB;
--c--theme--colors--info-500: #055FD2;
--c--theme--colors--info-600: #0556BF;
--c--theme--colors--info-700: #044395;
--c--theme--colors--info-800: #033474;
--c--theme--colors--info-900: #022858;
--c--theme--colors--greyscale-100: #FAFAFB;
--c--theme--colors--greyscale-200: #F3F4F4;
--c--theme--colors--greyscale-300: #E7E8EA;
--c--theme--colors--greyscale-400: #C2C6CA;
--c--theme--colors--greyscale-500: #9EA3AA;
--c--theme--colors--greyscale-600: #79818A;
--c--theme--colors--greyscale-700: #555F6B;
--c--theme--colors--greyscale-800: #303C4B;
--c--theme--colors--greyscale-900: #0C1A2B;
--c--theme--colors--greyscale-000: #FFFFFF;
--c--theme--colors--primary-100: #EBF2FC;
--c--theme--colors--primary-200: #8CB5EA;
--c--theme--colors--primary-300: #5894E1;
--c--theme--colors--primary-400: #377FDB;
--c--theme--colors--primary-500: #055FD2;
--c--theme--colors--primary-600: #0556BF;
--c--theme--colors--primary-700: #044395;
--c--theme--colors--primary-800: #033474;
--c--theme--colors--primary-900: #022858;
--c--theme--colors--success-100: #EFFCD3;
--c--theme--colors--success-200: #DBFAA9;
--c--theme--colors--success-300: #BEF27C;
--c--theme--colors--success-400: #A0E659;
--c--theme--colors--success-500: #76D628;
--c--theme--colors--success-600: #5AB81D;
--c--theme--colors--success-700: #419A14;
--c--theme--colors--success-800: #2C7C0C;
--c--theme--colors--success-900: #1D6607;
--c--theme--colors--warning-100: #FFF8CD;
--c--theme--colors--warning-200: #FFEF9B;
--c--theme--colors--warning-300: #FFE469;
--c--theme--colors--warning-400: #FFDA43;
--c--theme--colors--warning-500: #FFC805;
--c--theme--colors--warning-600: #DBA603;
--c--theme--colors--warning-700: #B78702;
--c--theme--colors--warning-800: #936901;
--c--theme--colors--warning-900: #7A5400;
--c--theme--colors--danger-100: #F4B0B0;
--c--theme--colors--danger-200: #EE8A8A;
--c--theme--colors--danger-300: #E65454;
--c--theme--colors--danger-400: #E13333;
--c--theme--colors--danger-500: #DA0000;
--c--theme--colors--danger-600: #C60000;
--c--theme--colors--danger-700: #9B0000;
--c--theme--colors--danger-800: #780000;
--c--theme--colors--danger-900: #5C0000;
--c--theme--colors--primary-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--success-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--warning-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--font--sizes--h1: 1.75rem;
--c--theme--font--sizes--h2: 1.375rem;
--c--theme--font--sizes--h3: 1.125rem;
--c--theme--font--sizes--h4: 0.8125rem;
--c--theme--font--sizes--h5: 0.625rem;
--c--theme--font--sizes--h6: 0.5rem;
--c--theme--font--sizes--l: 1rem;
--c--theme--font--sizes--m: 0.8125rem;
--c--theme--font--sizes--s: 0.6875rem;
--c--theme--font--weights--thin: 200;
--c--theme--font--weights--light: 300;
--c--theme--font--weights--regular: 400;
--c--theme--font--weights--medium: 500;
--c--theme--font--weights--bold: 600;
--c--theme--font--weights--extrabold: 700;
--c--theme--font--weights--black: 800;
--c--theme--font--families--base: "Roboto Flex Variable", sans-serif;
--c--theme--font--families--accent: "Roboto Flex Variable", sans-serif;
--c--theme--font--letterspacings--h1: normal;
--c--theme--font--letterspacings--h2: normal;
--c--theme--font--letterspacings--h3: normal;
--c--theme--font--letterspacings--h4: normal;
--c--theme--font--letterspacings--h5: 1px;
--c--theme--font--letterspacings--h6: normal;
--c--theme--font--letterspacings--l: normal;
--c--theme--font--letterspacings--m: normal;
--c--theme--font--letterspacings--s: normal;
--c--theme--spacings--xl: 4rem;
--c--theme--spacings--l: 3rem;
--c--theme--spacings--b: 1.625rem;
--c--theme--spacings--s: 1rem;
--c--theme--spacings--t: 0.5rem;
--c--theme--spacings--st: 0.25rem;
--c--theme--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--c--theme--transitions--duration: 250ms;
--c--theme--breakpoints--xs: 0;
--c--theme--breakpoints--sm: 576px;
--c--theme--breakpoints--md: 768px;
--c--theme--breakpoints--lg: 992px;
--c--theme--breakpoints--xl: 1200px;
--c--theme--breakpoints--xxl: 1400px;
}
.cunningham-theme--dark{
--c--theme--colors--greyscale-100: #182536;
--c--theme--colors--greyscale-200: #303C4B;
--c--theme--colors--greyscale-300: #555F6B;
--c--theme--colors--greyscale-400: #79818A;
--c--theme--colors--greyscale-500: #9EA3AA;
--c--theme--colors--greyscale-600: #C2C6CA;
--c--theme--colors--greyscale-700: #E7E8EA;
--c--theme--colors--greyscale-800: #F3F4F4;
--c--theme--colors--greyscale-900: #FAFAFB;
--c--theme--colors--greyscale-000: #0C1A2B;
--c--theme--colors--primary-100: #3B4C62;
--c--theme--colors--primary-200: #4D6481;
--c--theme--colors--primary-300: #6381A6;
--c--theme--colors--primary-400: #7FA5D5;
--c--theme--colors--primary-500: #8CB5EA;
--c--theme--colors--primary-600: #A3C4EE;
--c--theme--colors--primary-700: #C3D8F4;
--c--theme--colors--primary-800: #DDE9F8;
--c--theme--colors--primary-900: #F4F8FD;
--c--theme--colors--success-100: #EEF8D7;
--c--theme--colors--success-200: #D9F1B2;
--c--theme--colors--success-300: #BDE985;
--c--theme--colors--success-400: #A0E25D;
--c--theme--colors--success-500: #76D628;
--c--theme--colors--success-600: #5BB520;
--c--theme--colors--success-700: #43941A;
--c--theme--colors--success-800: #307414;
--c--theme--colors--success-900: #225D10;
--c--theme--colors--warning-100: #F7F3D5;
--c--theme--colors--warning-200: #F0E5AA;
--c--theme--colors--warning-300: #E8D680;
--c--theme--colors--warning-400: #E3C95F;
--c--theme--colors--warning-500: #D9B32B;
--c--theme--colors--warning-600: #BD9721;
--c--theme--colors--warning-700: #9D7B1C;
--c--theme--colors--warning-800: #7E6016;
--c--theme--colors--warning-900: #684D12;
--c--theme--colors--danger-100: #F8D0D0;
--c--theme--colors--danger-200: #F09898;
--c--theme--colors--danger-300: #F09898;
--c--theme--colors--danger-400: #ED8585;
--c--theme--colors--danger-500: #E96666;
--c--theme--colors--danger-600: #DD6666;
--c--theme--colors--danger-700: #C36666;
--c--theme--colors--danger-800: #AE6666;
--c--theme--colors--danger-900: #9D6666;
}

View File

@@ -0,0 +1,30 @@
@use "@openfun/cunningham-react/sass/fonts";
@use "@openfun/cunningham-react/sass/icons";
@use "@openfun/cunningham-react/style";
@use "cunningham-tokens";
:root {
font-family: Roboto, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
background-color: var(--c--theme--colors--greyscale-000);
}
.center {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 1rem;
h1, h3 {
margin-bottom: 40px;
}
}

13
src/frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.scss'
import {AppProvider} from "./components";
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AppProvider>
<App />
</AppProvider>
</React.StrictMode>,
)

7
src/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly VITE_PUBLIC_API_ROOT_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})