initialize project structure with essential files and configurations

This commit is contained in:
Benito 2025-03-20 11:32:27 -06:00
parent e3f5075c18
commit 2526a91463
31 changed files with 5059 additions and 0 deletions

View File

@ -0,0 +1,23 @@
module.exports = {
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"prettier",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
env: {
browser: true,
es2021: true,
},
settings: {
react: {
version: "detect",
},
},
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
},
};

24
writers-delight-main/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 marmelab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,38 @@
# writers-delight
Write notes, essays, and stories with an AI assistant ([live demo](https://marmelab.com/writers-delight)).
[![Writer's Delight](./public/writers-delight.png)](https://marmelab.com/writers-delight)
This demo uses [react-admin](https://marmelab.com/react-admin)'s built-in [AI capabilities](https://marmelab.com/react-admin/PredictiveTextInput.html) to provide an inline writing assistant. Try editing a composition to see text suggestions appearing in ghost text.
By default, the suggestions use fake latin text, but you can connect the app to OpenAI to get real suggestions powered by ChatGPT. Your OpenAI API key will not be sent to any third-party, just to the OpenAI API.
This is an offline-first application: all your compositions are stored in your browser's local storage. You can even use it offline.
## Installation
Install the application dependencies by running:
```sh
yarn
```
You will need an active subscription for [React-admin Enterprise Edition](https://marmelab.com/ra-enterprise/).
## Development
Start the application in development mode by running:
```sh
yarn dev
```
## Production
Build the application in production mode and deploy it to gh-pages by running:
```sh
yarn build
yarn deploy
```

View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<link rel="manifest" href="./manifest.json" />
<link rel="shortcut icon" href="./favicon.ico" />
<title>writers-delight</title>
<style>
body {
margin: 0;
padding: 0;
font-family: sans-serif;
background-color: #fff;
}
.loader-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: #fafafa;
}
/* CSS Spinner from https://projects.lukehaas.me/css-loaders/ */
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
}
.loader {
color: #283593;
font-size: 11px;
text-indent: -99999em;
margin: 55px auto;
position: relative;
width: 10em;
height: 10em;
box-shadow: inset 0 0 0 1em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
}
.loader:before,
.loader:after {
position: absolute;
content: "";
}
.loader:before {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 10.2em 0 0 10.2em;
top: -0.1em;
left: -0.1em;
-webkit-transform-origin: 5.2em 5.1em;
transform-origin: 5.2em 5.1em;
-webkit-animation: load2 2s infinite ease 1.5s;
animation: load2 2s infinite ease 1.5s;
}
.loader:after {
width: 5.2em;
height: 10.2em;
background: #fafafa;
border-radius: 0 10.2em 10.2em 0;
top: -0.1em;
left: 5.1em;
-webkit-transform-origin: 0px 5.1em;
transform-origin: 0px 5.1em;
-webkit-animation: load2 2s infinite ease;
animation: load2 2s infinite ease;
}
@-webkit-keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
</style>
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Unna:wght@300;400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root">
<div class="loader-container">
<div class="loader">Loading...</div>
</div>
</div>
</body>
<script type="module" src="/src/index.tsx"></script>
</html>

View File

@ -0,0 +1,41 @@
{
"name": "writers-delight",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"deploy": "gh-pages -d dist",
"type-check": "tsc --noEmit",
"lint": "eslint --fix --ext .js,.jsx,.ts,.tsx ./src",
"format": "prettier --write ./src"
},
"dependencies": {
"@react-admin/ra-ai": "^5.0.0-beta.1",
"@react-admin/ra-form-layout": "^5.0.0-beta.0",
"highlight-search-term": "^1.0.0",
"lodash": "^4.17.21",
"ra-data-local-storage": "^5.0.1",
"ra-input-rich-text": "^5.0.1",
"react": "^18.3.1",
"react-admin": "^5.0.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/lodash": "^4.17.5",
"@types/node": "^20.14.8",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"@typescript-eslint/parser": "^5.60.1",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"gh-pages": "^6.1.1",
"prettier": "^2.8.8",
"typescript": "^5.5.2",
"vite": "^5.3.1"
}
}

View File

@ -0,0 +1 @@
module.exports = {}

Binary file not shown.

After

Width: 48px  |  Height: 48px  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

(image error) Size: 17 KiB

View File

@ -0,0 +1,15 @@
{
"short_name": "writers-delight",
"name": "{{name}}",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

Binary file not shown.

After

(image error) Size: 102 KiB

View File

@ -0,0 +1,18 @@
import { Admin, Resource } from "react-admin";
import { createTheme } from "@mui/material";
import { dataProvider } from "./dataProvider";
import { Layout } from "./Layout";
import compositions from "./compositions";
const theme = createTheme({
typography: {
fontFamily: ["Unna", "Georgia", "Times New Roman", "serif"].join(","),
},
});
export const App = () => (
<Admin dataProvider={dataProvider} layout={Layout} theme={theme}>
<Resource name="compositions" {...compositions} />
</Admin>
);

View File

@ -0,0 +1,9 @@
import { Box } from "@mui/material";
import { SplashScreen } from "./SplashScreen";
export const Layout = ({ children }: any) => (
<Box display="flex" flex="1 0 auto" width="100%">
{children}
<SplashScreen />
</Box>
);

View File

@ -0,0 +1,88 @@
import { useStore } from "react-admin";
import {
Button,
Dialog,
DialogActions,
DialogTitle,
DialogContent,
IconButton,
Typography,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import CodeIcon from "@mui/icons-material/Code";
export const SplashScreen = () => {
const [hasSeenSplashScreen, setHasSeenSplashScreen] = useStore(
"hasSeenSplashScreen",
false
);
const handleClose = () => {
setHasSeenSplashScreen(true);
};
return (
<Dialog
open={!hasSeenSplashScreen}
onClose={handleClose}
fullWidth
maxWidth="sm"
>
<DialogTitle align="center">
<img
src="./illustration.svg"
alt="writer by Hey Rabbit from Noun Project (CC BY 3.0)"
width="50%"
/>
<Typography variant="h2">Writer&apos;s Delight</Typography>
<Typography variant="h6" color="text.secondary" gutterBottom>
Write notes, essays, and stories with an AI assistant.
</Typography>
<IconButton
aria-label="close"
onClick={handleClose}
sx={{
position: "absolute",
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" gutterBottom>
This demo uses{" "}
<a href="https://marmelab.com/react-admin/PredictiveTextInput.html">
react-admin
</a>
&apos;s built-in{" "}
<a href="https://marmelab.com/react-admin/PredictiveTextInput.html">
AI capabilities
</a>{" "}
to provide an inline writing assistant. Try editing a composition to
see text suggestions appearing in ghost text.
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
By default, the suggestions use fake latin text, but you can connect
the app to{" "}
<a href="https://platform.openai.com/docs/introduction">OpenAI</a> to
get real suggestions powered by ChatGPT. Your OpenAI API key will not
be sent to any third-party, just to the OpenAI API.
</Typography>
<Typography variant="body2" color="text.secondary">
This is an offline-first application: all your compositions are stored
in your browser&apos;s local storage. You can even use it offline.
</Typography>
</DialogContent>
<DialogActions>
<Button
component="a"
startIcon={<CodeIcon />}
href="https://github.com/marmelab/writers-delight"
>
Source for this demo
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,167 @@
import * as React from "react";
import { useStore } from "react-admin";
import {
Box,
Button,
Switch,
FormGroup,
FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
IconButton,
InputAdornment,
MenuItem,
TextField,
} from "@mui/material";
import KeyIcon from "@mui/icons-material/Key";
import CloseIcon from "@mui/icons-material/Close";
import SettingsIcon from "@mui/icons-material/Settings";
export const AISwitch = () => {
const [assistantEnabled, setAssistantEnabled] = useStore(
"assistantEnabled",
true
);
const [model, setModel] = useStore("assistantModel", "gpt-3.5-turbo");
const [open, setOpen] = React.useState(false);
const handleToggle = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.checked && !localStorage.getItem("ra-ai.openai-api-key")) {
setOpen(true);
} else {
setAssistantEnabled(event.target.checked);
}
};
const handleConfigure = () => {
setOpen(true);
};
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
const data = new FormData(event.currentTarget);
const apiKey = data.get("api_key") as string;
localStorage.setItem("ra-ai.openai-api-key", apiKey);
setAssistantEnabled(true);
setOpen(false);
event.preventDefault();
};
return (
<Box
position="fixed"
bottom={0}
padding="0.5em 0.5em 0.5em 1em"
zIndex={2}
width={319}
bgcolor="background.default"
display="flex"
justifyContent="space-between"
alignItems="center"
>
<FormGroup>
<FormControlLabel
control={
<Switch checked={assistantEnabled} onChange={handleToggle} />
}
label="AI Assistant"
/>
</FormGroup>
{assistantEnabled ? (
<IconButton onClick={handleConfigure}>
<SettingsIcon fontSize="small" />
</IconButton>
) : (
<Box
component="span"
sx={{
bgcolor: "primary.main",
width: 10,
height: 10,
borderRadius: "50%",
marginRight: 1.5,
}}
/>
)}
<Dialog
fullWidth
maxWidth="sm"
open={open}
onClose={() => setOpen(false)}
>
<DialogTitle>OpenAI API key</DialogTitle>
<IconButton
aria-label="close"
onClick={() => setOpen(false)}
sx={{
position: "absolute",
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
<form onSubmit={handleSubmit}>
<DialogContent>
<DialogContentText>
The AI assistant relies on the{" "}
<a href="https://openai.com/blog/openai-api">
OpenAI completion API
</a>
, powered by ChatGPT.
<br />
<br />
To enable the assistant, please enter your OpenAI API key. If you
don&apos;t enter an API key, the assistant will suggest lorem
ipsum text.
<br />
<br />
</DialogContentText>
<TextField
autoFocus
fullWidth
name="api_key"
label="API key"
helperText="This key will not be sent to any third-party, just to the OpenAI API."
defaultValue={localStorage.getItem("ra-ai.openai-api-key")}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<KeyIcon />
</InputAdornment>
),
}}
/>
<TextField
select
fullWidth
name="model"
label="Model"
value={model}
onChange={(e) => setModel(e.target.value)}
sx={{ mt: 2 }}
>
<MenuItem value="gpt-3.5-turbo">GPT-3.5 Turbo</MenuItem>
<MenuItem value="gpt-4-turbo">GPT-4 Turbo</MenuItem>
</TextField>
</DialogContent>
<DialogActions sx={{ mb: 1 }}>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button
type="submit"
onClick={() => setOpen(false)}
color="primary"
variant="contained"
sx={{ mr: 2 }}
>
Save
</Button>
</DialogActions>
</form>
</Dialog>
</Box>
);
};

View File

@ -0,0 +1,55 @@
import { EditBase, Form, useStore } from "react-admin";
import { AutoSave } from "@react-admin/ra-form-layout";
import { PredictiveTextInput } from "@react-admin/ra-ai";
import { Box, Container } from "@mui/material";
import { MoreActionsButton } from "./MoreActionsButton";
import { firstLine } from "./textUtils";
import type { Composition } from "./types";
export const CompositionEdit = ({ id }: { id: number }) => {
const [assistantEnabled] = useStore("assistantEnabled", true);
const [model] = useStore("assistantModel", "gpt-3.5-turbo");
return (
<EditBase<Composition>
id={id}
sx={{ width: "100%" }}
actions={false}
mutationMode="optimistic"
transform={(data) => ({
...data,
title: firstLine(data.body),
updated_at: new Date().toISOString(),
})}
component="div"
>
<Box width="100%" mt={1}>
<Form key={id}>
<Box width="100%" display="flex" alignItems="center" px={1}>
<Box flex="1" />
<AutoSave debounce={1000} />
<MoreActionsButton />
</Box>
<Container maxWidth="sm">
<PredictiveTextInput
source="body"
variant="standard"
label={false}
helperText={false}
autoFocus
multiline
minRows={20}
fullWidth
sx={{
"& .MuiInputBase-root:before": { display: "none" },
"& .MuiInputBase-root:after": { display: "none" },
}}
meta={{ model }}
queryOptions={{ enabled: assistantEnabled }}
/>
</Container>
</Form>
</Box>
</EditBase>
);
};

View File

@ -0,0 +1,30 @@
import { Container, Typography } from "@mui/material";
export const CompositionEditEmpty = () => (
<Container
maxWidth="sm"
sx={{
display: "flex",
flexDirection: "column",
height: "100vh",
justifyContent: "center",
}}
>
<Typography align="center" gutterBottom>
<img
src="./illustration.svg"
alt="writer by Hey Rabbit from Noun Project (CC BY 3.0)"
width="50%"
style={{ opacity: 0.6 }}
/>
</Typography>
<Typography variant="h2" color="text.secondary" align="center" mb={1}>
Writer&apos;s Delight
</Typography>
<Typography variant="body1" color="text.secondary" align="center">
Write notes, essays, and stories
<br />
with an AI assistant.
</Typography>
</Container>
);

View File

@ -0,0 +1,142 @@
import { ReactNode } from "react";
import {
InfiniteList,
SimpleList,
DateField,
useRedirect,
FilterLiveSearch,
useListContext,
} from "react-admin";
import { Box, Stack } from "@mui/material";
import { useLocation, matchPath } from "react-router-dom";
import { CompositionEdit } from "./CompositionEdit";
import { CompositionEditEmpty } from "./CompositionEditEmpty";
import { CompositionListEmpty } from "./CompositionListEmpty";
import { CreateCompositionButton } from "./CreateCompositionButton";
import { AISwitch } from "./AISwitch";
import { notFirstLine } from "./textUtils";
import { HighlightSearchTerm } from "./HighlighhtSearchTerm";
const ListActions = () => (
<Stack direction="row" sx={{ px: 1, mt: 1, mb: 1 }}>
<CreateCompositionButton />
</Stack>
);
interface ListContentProps {
empty: ReactNode;
notEmpty: ReactNode;
}
const ListContent = ({ empty, notEmpty }: ListContentProps) => {
const { isLoading, data, filterValues } = useListContext();
return !isLoading &&
data?.length === 0 &&
(!filterValues || Object.keys(filterValues).length === 0)
? empty
: notEmpty;
};
export const CompositionList = () => {
const redirect = useRedirect();
const location = useLocation();
const match = matchPath("/compositions/:id", location.pathname);
return (
<Box
display="flex"
gap={2}
width="100%"
sx={{
"& ::highlight(search)": {
backgroundColor: "yellow",
color: "black",
},
}}
>
<Box
width={320}
flexShrink={0}
sx={{ overflowY: "auto" }}
height="100vh"
borderRight="solid 1px #ccc"
position="fixed"
paddingBottom={3}
>
<InfiniteList
actions={<ListActions />}
empty={false}
sort={{ field: "updated_at", order: "DESC" }}
disableSyncWithLocation
component="div"
queryOptions={{
onSuccess: (data: any) => {
if (
!match &&
data.pages.length > 0 &&
data.pages[0].data.length > 0
) {
redirect(`/compositions/${data.pages[0].data[0].id}`);
}
},
}}
>
<ListContent
empty={<CompositionListEmpty />}
notEmpty={
<>
<FilterLiveSearch
fullWidth
// @ts-ignore
variant="standard"
label="Search all compositions"
hiddenLabel
sx={{
px: 2,
"& .MuiInput-root:before": { display: "none" },
"& .MuiInput-root:after": { display: "none" },
"& .MuiSvgIcon-root": { fontSize: "1.25rem" },
}}
/>
<SimpleList
primaryText="%{title}"
secondaryText={(record) =>
notFirstLine(record.body).substring(0, 50).trim() || <br />
}
tertiaryText={(record) => (
<DateField record={record} source="updated_at" />
)}
sx={{
py: 0,
"& .MuiListItemText-secondary": {
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
width: 268,
},
}}
rowSx={(record) =>
!!match &&
parseInt((match as any).params.id, 10) === record.id
? { backgroundColor: "#eee" }
: null
}
/>
</>
}
/>
<HighlightSearchTerm />
</InfiniteList>
<AISwitch />
</Box>
<Box flex="1" marginLeft="320px">
{match ? (
<CompositionEdit id={(match as any).params.id} />
) : (
<CompositionEditEmpty />
)}
</Box>
</Box>
);
};

View File

@ -0,0 +1,11 @@
import { Box, Typography } from "@mui/material";
import TurnLeftIcon from "@mui/icons-material/TurnLeft";
export const CompositionListEmpty = () => (
<Box m={2} display="flex">
<Typography variant="body1" color="text.secondary" flex={1}>
Write your first composition
</Typography>
<TurnLeftIcon sx={{ transform: "rotate(90deg)" }} />
</Box>
);

View File

@ -0,0 +1,36 @@
import { useCreate, useRedirect } from "react-admin";
import { IconButton, Tooltip } from "@mui/material";
import EditNoteIcon from "@mui/icons-material/EditNote";
import { Composition } from "./types";
export const CreateCompositionButton = () => {
const redirect = useRedirect();
const options = {
onSuccess: (data: Composition) => {
redirect(`/compositions/${data.id}`);
},
};
const [create] = useCreate<Composition>();
return (
<Tooltip title="New composition" placement="bottom">
<IconButton
onClick={() =>
create(
"compositions",
{
data: {
title: "New composition",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
},
options
)
}
size="small"
>
<EditNoteIcon fontSize="small" />
</IconButton>
</Tooltip>
);
};

View File

@ -0,0 +1,29 @@
import { useEffect } from "react";
import { useListContext } from "react-admin";
import { useLocation } from "react-router-dom";
import debounce from "lodash/debounce";
import { highlightSearchTerm } from "highlight-search-term";
/**
* Watch the current search term and highlight it in the list of compositions
*
* Uses the CSS Custom Highlight API (not supported on Firefox)
* @see https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API
*/
export const HighlightSearchTerm = () => {
const { filterValues } = useListContext();
const search = filterValues.q?.toLowerCase() || "";
// the location pathname is used to trigger the user clicks on a composition
const location = useLocation();
useEffect(() => {
debounceHighlightSearchTerm({
search,
selector: ".MuiList-root, [contenteditable=true]",
});
}, [location.pathname, search]);
return null;
};
// debounce allows to delay the highlight, which allows the composition to render before the highlight
const debounceHighlightSearchTerm = debounce(highlightSearchTerm, 100);

View File

@ -0,0 +1,78 @@
import * as React from "react";
import {
useDeleteWithConfirmController,
useRecordContext,
Confirm,
} from "react-admin";
import { IconButton, Menu, MenuItem, ListItemIcon } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import type { Composition } from "./types";
export const MoreActionsButton = () => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const menuOpen = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const record = useRecordContext<Composition>();
const { open, isLoading, handleDialogOpen, handleDialogClose, handleDelete } =
useDeleteWithConfirmController({
record,
onClick: handleClose,
redirect: "list",
});
return (
<>
<IconButton
aria-controls={open ? "basic-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleClick}
size="small"
>
<MoreVertIcon fontSize="small" />
</IconButton>
<Menu
id="basic-menu"
anchorEl={anchorEl}
open={menuOpen}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "basic-button",
}}
>
<MenuItem
onClick={(e) => {
handleClose();
handleDialogOpen(e);
}}
sx={{ color: "error.main" }}
>
<ListItemIcon>
<DeleteIcon color="error" />
</ListItemIcon>
Delete
</MenuItem>
</Menu>
<Confirm
isOpen={open}
loading={isLoading}
title={"ra.message.delete_title"}
content={"ra.message.delete_content"}
confirmColor={"primary"}
translateOptions={{
name: "composition",
id: record?.id,
}}
onConfirm={handleDelete}
onClose={handleDialogClose}
/>
</>
);
};

View File

@ -0,0 +1,5 @@
import { CompositionList } from "./CompositionList";
export default {
list: CompositionList,
};

View File

@ -0,0 +1,3 @@
export const firstLine = (text: string) => (text ? text.split("\n")[0] : "");
export const notFirstLine = (text: string) =>
text ? text.split("\n").slice(1).join("\n") : "";

View File

@ -0,0 +1,7 @@
export interface Composition {
id: number;
title: string;
body: string;
updated_at: string;
created_at: string;
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

View File

@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
define: {
'process.env': process.env,
},
server: {
host: true,
},
base: './',
});

File diff suppressed because it is too large Load Diff