initialize project structure with essential files and configurations
This commit is contained in:
parent
e3f5075c18
commit
2526a91463
writers-delight-main
.eslintrc.js.gitignoreLICENSEREADME.mdindex.htmlpackage.jsonprettier.config.js
public
src
App.tsxLayout.tsxSplashScreen.tsx
tsconfig.jsonvite.config.tsyarn.lockcompositions
AISwitch.tsxCompositionEdit.tsxCompositionEditEmpty.tsxCompositionList.tsxCompositionListEmpty.tsxCreateCompositionButton.tsxHighlighhtSearchTerm.tsxMoreActionsButton.tsxindex.tstextUtils.tstypes.ts
dataProvider.tsindex.tsxvite-env.d.ts
|
@ -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: "^_" }],
|
||||
},
|
||||
};
|
|
@ -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?
|
|
@ -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.
|
|
@ -0,0 +1,38 @@
|
|||
# writers-delight
|
||||
|
||||
Write notes, essays, and stories with an AI assistant ([live demo](https://marmelab.com/writers-delight)).
|
||||
|
||||
[](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
|
||||
```
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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 |
|
@ -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 |
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
|
@ -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'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>
|
||||
'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'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>
|
||||
);
|
||||
};
|
|
@ -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'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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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's Delight
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" align="center">
|
||||
Write notes, essays, and stories
|
||||
<br />
|
||||
with an AI assistant.
|
||||
</Typography>
|
||||
</Container>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
import { CompositionList } from "./CompositionList";
|
||||
|
||||
export default {
|
||||
list: CompositionList,
|
||||
};
|
|
@ -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") : "";
|
|
@ -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
|
@ -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>
|
||||
);
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue