├── .github └── workflows │ └── build-image.yml ├── .gitignore ├── .gitpod.yml ├── .gitpod └── docker-compose.override.yml ├── Makefile ├── README.md ├── client ├── .eslintrc.cjs ├── .gitignore ├── Dockerfile ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── components │ │ ├── ColorSchemeToggle.tsx │ │ ├── Field.tsx │ │ ├── Header.tsx │ │ ├── Layout.tsx │ │ ├── ModalWindow.tsx │ │ ├── Sidebar.tsx │ │ └── widgets │ │ │ ├── BlockNoteEditor.css │ │ │ ├── BlockNoteEditor.tsx │ │ │ ├── FileInput.tsx │ │ │ └── TextInput.tsx │ ├── contexts.ts │ ├── deserializers │ │ ├── Field.tsx │ │ ├── Form.tsx │ │ ├── ServerRenderedField.tsx │ │ └── widgets │ │ │ ├── BlockNoteEditor.tsx │ │ │ ├── FileInput.tsx │ │ │ ├── Select.tsx │ │ │ ├── TextInput.tsx │ │ │ └── base.tsx │ ├── main.tsx │ ├── types.ts │ ├── utils.ts │ ├── views │ │ ├── ConfirmDelete.tsx │ │ ├── Home.tsx │ │ ├── Login.tsx │ │ ├── MediaForm.tsx │ │ ├── MediaIndex.tsx │ │ ├── PostForm.tsx │ │ └── PostIndex.tsx │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── docker-compose.yml └── server ├── Dockerfile ├── djangopress ├── __init__.py ├── adapters.py ├── asgi.py ├── auth │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_user_is_temporary.py │ │ └── __init__.py │ ├── models.py │ └── views.py ├── context_providers.py ├── media │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_mediaasset_file.py │ │ ├── 0003_thumbnail_alter_mediaasset_file_mediaasset_thumbnail.py │ │ ├── 0004_mediaasset_owner.py │ │ ├── 0005_mediaasset_file_content_type_mediaasset_file_hash_and_more.py │ │ ├── 0006_remove_mediaasset_status.py │ │ └── __init__.py │ ├── models.py │ ├── utils.py │ └── views.py ├── posts │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_post_content.py │ │ ├── 0003_post_owner.py │ │ └── __init__.py │ ├── models.py │ └── views.py ├── settings.py ├── urls.py ├── utils.py ├── views.py ├── widgets.py └── wsgi.py ├── manage.py ├── media └── .gitignore ├── poetry.lock └── pyproject.toml /.github/workflows/build-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and push image to GHCR 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | docker-build: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: read 14 | packages: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Login to GHCR 19 | uses: docker/login-action@v3 20 | with: 21 | registry: https://ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Set up QEMU 25 | uses: docker/setup-qemu-action@v3 26 | - name: Set up Docker Buildx 27 | uses: docker/setup-buildx-action@v3 28 | - name: Extract metadata for Docker images 29 | id: meta 30 | uses: docker/metadata-action@v5 31 | with: 32 | images: ghcr.io/django-bridge/django-react-cms 33 | tags: | 34 | type=ref,event=branch 35 | type=ref,event=tag 36 | type=sha,prefix=git- 37 | flavor: | 38 | latest=${{ github.ref == 'refs/heads/main' }} 39 | - name: Build and push 40 | uses: docker/build-push-action@v6 41 | with: 42 | platforms: linux/amd64,linux/arm64 43 | context: . 44 | file: ./server/Dockerfile 45 | target: prod 46 | push: true 47 | tags: ${{ steps.meta.outputs.tags }} 48 | labels: ${{ steps.meta.outputs.labels }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /server/db.sqlite3 3 | /docker-compose.override.yml 4 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - init: cp .gitpod/docker-compose.override.yml . && make setup 3 | command: make start 4 | 5 | ports: 6 | - port: 5173 # Vite server (serves compiled JS/CSS files in development) 7 | visibility: public 8 | - port: 8000 9 | onOpen: open-preview 10 | 11 | vscode: 12 | extensions: 13 | - ms-python.python 14 | - esbenp.prettier-vscode 15 | - dbaeumer.vscode-eslint 16 | -------------------------------------------------------------------------------- /.gitpod/docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | # On GitPod, the Docker containers need to run with UID 33333 to avoid permissions errors 2 | 3 | services: 4 | server: 5 | user: "33333" 6 | build: 7 | args: 8 | - UID=33333 9 | environment: 10 | - VITE_SERVER_ORIGIN=https://5173-${GITPOD_WORKSPACE_ID}.${GITPOD_WORKSPACE_CLUSTER_HOST}/static 11 | 12 | client: 13 | user: "33333" 14 | build: 15 | args: 16 | - UID=33333 17 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: setup rebuild migrate superuser start 2 | 3 | setup: rebuild migrate ## Sets up development environment 4 | docker compose run client npm install 5 | 6 | rebuild: ## Rebuilds the docker containers 7 | docker compose pull 8 | docker compose build 9 | 10 | migrate: ## Run Django migrations 11 | docker compose run server django-admin migrate 12 | 13 | superuser: ## Create a superuser 14 | docker compose run server django-admin createsuperuser 15 | 16 | start: ## Starts the docker containers 17 | docker compose up 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Django/React CMS Demo 2 | 3 | This repository contains a clone of Wordpress built with Django and React using [Django Bridge](https://django-bridge.org) to connect them. 4 | 5 | This demonstrates how to build an application with all logic implemented in Django views and React components used for rendering. 6 | 7 | [See the demo live here](https://demo.django-render.org) 8 | 9 | ## Running it 10 | 11 | To get a sense of what Django Bridge is like to develop with, give it a try in one of the following ways. 12 | I'd recommend editing [one of the frontend views](https://github.com/django-bridge/django-react-cms/blob/main/client/src/views/Home/HomeView.tsx) and see it instantly re-render with your changes! 13 | Or, if you're more of a backend dev, have a look at the [backend views](https://github.com/django-bridge/django-react-cms/blob/main/server/djangopress/posts/views.py) that supply the data for the frontend views to render. 14 | 15 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/django-bridge/django-react-cms) 16 | 17 | ### With Docker compose 18 | 19 | The easiest way to get this up and running is to use `docker compose`, a subcommand of Docker. Make sure you have Docker installed then run: 20 | 21 | ``` 22 | make setup 23 | make superuser 24 | make start 25 | ``` 26 | 27 | Then Djangopress should be running on [localhost:8000](http://localhost:8000) 28 | 29 | ### Without Docker compose 30 | 31 | It's possible to run this without docker compose as well, you will need to have Python 11 and Node JS installed locally. 32 | 33 | First open two terminals. 34 | 35 | In the first terminal, run the following to install and start the Vite server, which builds and serves the built JavaScript code containing the frontend: 36 | 37 | ``` 38 | cd client 39 | npm install 40 | npm run dev 41 | ``` 42 | 43 | This should start a server at [localhost:5173](http://localhost:5173), there shouldn't be anything here, this will be used by the Django server to fetch freshly built JavaScript. 44 | 45 | In the second terminal, run the following to install Django, create the database, create a user, then start the Django devserver: 46 | 47 | ``` 48 | cd server 49 | poetry install 50 | poetry run python manage.py migrate 51 | poetry run python manage.py createsuperuser 52 | poetry run python manage.py runserver 53 | ``` 54 | 55 | Then Djangopress should be running on [localhost:8000](http://localhost:8000) 56 | -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "react-refresh/only-export-components": [ 14 | "warn", 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.13.1-bookworm-slim AS dev 2 | 3 | ARG UID=1000 4 | RUN userdel node && useradd djangopress --uid ${UID} -l --create-home && mkdir /client && chown djangopress /client 5 | 6 | WORKDIR /client 7 | USER djangopress 8 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default { 18 | // other rules... 19 | parserOptions: { 20 | ecmaVersion: "latest", 21 | sourceType: "module", 22 | project: ["./tsconfig.json", "./tsconfig.node.json"], 23 | tsconfigRootDir: __dirname, 24 | }, 25 | }; 26 | ``` 27 | 28 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` 29 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list 31 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "djangopress", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@blocknote/mantine": "^0.13.4", 14 | "@blocknote/react": "^0.13.4", 15 | "@django-bridge/react": "^0.3.0", 16 | "@emotion/styled": "^11.11.0", 17 | "@mui/icons-material": "^5.15.3", 18 | "@mui/joy": "^5.0.0-beta.22", 19 | "@mui/styled-engine-sc": "^6.0.0-alpha.11", 20 | "@mui/x-data-grid": "^6.18.7", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "show-open-file-picker": "^0.3.0", 24 | "styled-components": "^6.1.8" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.2.43", 28 | "@types/react-dom": "^18.2.17", 29 | "@typescript-eslint/eslint-plugin": "^6.14.0", 30 | "@typescript-eslint/parser": "^6.14.0", 31 | "@vitejs/plugin-react-swc": "^3.5.0", 32 | "eslint": "^8.55.0", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "eslint-plugin-react-refresh": "^0.4.5", 35 | "typescript": "^5.2.2", 36 | "vite": "^5.0.8" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/components/ColorSchemeToggle.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useColorScheme } from "@mui/joy/styles"; 3 | import IconButton, { IconButtonProps } from "@mui/joy/IconButton"; 4 | 5 | import DarkModeRoundedIcon from "@mui/icons-material/DarkModeRounded"; 6 | import LightModeIcon from "@mui/icons-material/LightMode"; 7 | 8 | export default function ColorSchemeToggle(props: IconButtonProps) { 9 | const { onClick, sx, ...other } = props; 10 | const { mode, setMode } = useColorScheme(); 11 | const [mounted, setMounted] = React.useState(false); 12 | React.useEffect(() => { 13 | setMounted(true); 14 | }, []); 15 | if (!mounted) { 16 | return ( 17 | 25 | ); 26 | } 27 | return ( 28 | { 35 | if (mode === "light") { 36 | setMode("dark"); 37 | } else { 38 | setMode("light"); 39 | } 40 | onClick?.(event); 41 | }} 42 | sx={[ 43 | { 44 | "& > *:first-child": { 45 | display: mode === "dark" ? "none" : "initial", 46 | }, 47 | "& > *:last-child": { 48 | display: mode === "light" ? "none" : "initial", 49 | }, 50 | }, 51 | ...(Array.isArray(sx) ? sx : [sx]), 52 | ]} 53 | > 54 | 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /client/src/components/Field.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import FormControl from "@mui/joy/FormControl"; 3 | import FormHelperText from "@mui/joy/FormHelperText"; 4 | import FormLabel from "@mui/joy/FormLabel"; 5 | import WarningIcon from "@mui/icons-material/Warning"; 6 | 7 | export interface FieldProps { 8 | label: string; 9 | required: boolean; 10 | widget: ReactElement; 11 | helpText?: string; 12 | displayOptions?: { 13 | focusOnMount?: boolean; 14 | hideRequiredAsterisk?: boolean; 15 | }; 16 | errors: string[]; 17 | } 18 | 19 | function Field({ 20 | label, 21 | required, 22 | widget, 23 | helpText, 24 | displayOptions, 25 | errors, 26 | }: FieldProps): ReactElement { 27 | // Focus on mount 28 | const wrapperRef = React.useRef(null); 29 | React.useEffect(() => { 30 | if (displayOptions?.focusOnMount && wrapperRef.current) { 31 | const inputElement = wrapperRef.current.querySelector("input"); 32 | if (inputElement) { 33 | inputElement.focus(); 34 | } 35 | } 36 | }, [displayOptions?.focusOnMount]); 37 | 38 | return ( 39 | 40 | 41 | {label}{required && !displayOptions?.hideRequiredAsterisk && *} 42 | 43 | {widget} 44 | {helpText && ( 45 | 46 | )} 47 | 48 | {!!errors.length && ( 49 |
    50 | {errors.map((error) => ( 51 |
  • 52 | {error} 53 |
  • 54 | ))} 55 |
56 | )} 57 |
58 |
59 | ); 60 | } 61 | 62 | export default Field; 63 | -------------------------------------------------------------------------------- /client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import GlobalStyles from "@mui/joy/GlobalStyles"; 2 | import Sheet from "@mui/joy/Sheet"; 3 | import IconButton from "@mui/joy/IconButton"; 4 | import MenuIcon from "@mui/icons-material/Menu"; 5 | 6 | import { toggleSidebar } from "../utils"; 7 | 8 | export default function Header() { 9 | return ( 10 | 27 | ({ 29 | ":root": { 30 | "--Header-height": "52px", 31 | [theme.breakpoints.up("md")]: { 32 | "--Header-height": "0px", 33 | }, 34 | }, 35 | })} 36 | /> 37 | toggleSidebar()} 39 | variant="outlined" 40 | color="neutral" 41 | size="sm" 42 | > 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /client/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled, { keyframes } from "styled-components"; 3 | import { CssVarsProvider } from "@mui/joy/styles"; 4 | import CssBaseline from "@mui/joy/CssBaseline"; 5 | import Box from "@mui/joy/Box"; 6 | import Breadcrumbs from "@mui/joy/Breadcrumbs"; 7 | import { 8 | DirtyFormContext, 9 | Link as DjangoBridgeLink, 10 | MessagesContext, 11 | OverlayContext, 12 | } from "@django-bridge/react"; 13 | import Link from "@mui/joy/Link"; 14 | import Typography from "@mui/joy/Typography"; 15 | import WarningRounded from "@mui/icons-material/WarningRounded"; 16 | 17 | import HomeRoundedIcon from "@mui/icons-material/HomeRounded"; 18 | import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded"; 19 | 20 | import Sidebar from "./Sidebar"; 21 | import Header from "./Header"; 22 | import Button from "@mui/joy/Button"; 23 | import { SxProps } from "@mui/joy/styles/types"; 24 | 25 | const slideDown = keyframes` 26 | from { 27 | margin-top: -50px; 28 | } 29 | 30 | to { 31 | margin-top: 0 32 | } 33 | `; 34 | 35 | const UnsavedChangesWarningWrapper = styled.div` 36 | display: flex; 37 | flex-flow: row nowrap; 38 | align-items: center; 39 | gap: 20px; 40 | padding: 15px 20px; 41 | color: #2e1f5e; 42 | font-size: 15px; 43 | font-weight: 400; 44 | margin-top: 0; 45 | background-color: #ffdadd; 46 | animation: ${slideDown} 0.5s ease; 47 | 48 | p { 49 | line-height: 19.5px; 50 | } 51 | 52 | strong { 53 | font-weight: 700; 54 | } 55 | `; 56 | 57 | interface LayoutProps { 58 | title: string; 59 | breadcrumb?: { 60 | label: string; 61 | href?: string; 62 | }[]; 63 | renderHeaderButtons?: () => React.ReactNode; 64 | fullWidth?: boolean; 65 | hideHomeBreadcrumb?: boolean; 66 | } 67 | 68 | export default function Layout({ 69 | title, 70 | breadcrumb = [], 71 | renderHeaderButtons, 72 | fullWidth, 73 | hideHomeBreadcrumb, 74 | children, 75 | }: React.PropsWithChildren) { 76 | const { overlay } = React.useContext(OverlayContext); 77 | const { messages } = React.useContext(MessagesContext); 78 | const { unloadBlocked, confirmUnload } = React.useContext(DirtyFormContext); 79 | 80 | if (overlay) { 81 | // The view is being rendered in an overlay, no need to render the menus or base CSS 82 | return ( 83 | <> 84 | {unloadBlocked && ( 85 | 86 | 87 |

88 | You have unsaved changes. Please save or cancel 89 | before closing 90 |

91 |
92 | )} 93 | 100 | 101 | {title} 102 | 103 | {renderHeaderButtons && renderHeaderButtons()} 104 | {children} 105 | 106 | 107 | ); 108 | } 109 | 110 | return ( 111 | 112 | 113 | 114 |
115 | 116 | 117 | {unloadBlocked && ( 118 | 119 | 120 |

121 | You have unsaved changes. Please save your changes before 122 | leaving. 123 |

124 | 134 |
135 | )} 136 | {!!messages.length && ( 137 | 138 | {messages.map((message) => { 139 | const sx: SxProps = { 140 | px: 4, 141 | py: 2, 142 | color: "white", 143 | fontWeight: 500, 144 | backgroundColor: { 145 | success: "#1B8666", 146 | warning: "#FAA500", 147 | error: "#CA3B3B", 148 | }[message.level], 149 | }; 150 | if ("html" in message) { 151 | return ( 152 | 164 | ); 165 | } 166 | 167 | return ( 168 | 177 | {message.text} 178 | 179 | ); 180 | })} 181 | 182 | )} 183 | 202 | 203 | 204 | } 208 | sx={{ pl: 0, minHeight: "34px" }} 209 | > 210 | {!hideHomeBreadcrumb && ( 211 | 218 | 219 | 220 | )} 221 | {breadcrumb.map(({ label, href }) => 222 | href ? ( 223 | 232 | {label} 233 | 234 | ) : ( 235 | 240 | {label} 241 | 242 | ) 243 | )} 244 | 245 | 246 | 257 | 258 | {title} 259 | 260 | {renderHeaderButtons && renderHeaderButtons()} 261 | 262 | 263 | {children} 264 | 265 | 266 |
267 | 268 | ); 269 | } 270 | -------------------------------------------------------------------------------- /client/src/components/ModalWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, ReactElement } from "react"; 2 | import styled from "styled-components"; 3 | import { OverlayContext } from "@django-bridge/react"; 4 | import Drawer from "@mui/joy/Drawer"; 5 | import Modal from "@mui/joy/Modal"; 6 | import ModalDialog from "@mui/joy/ModalDialog"; 7 | 8 | const ModalWrapper = styled.div` 9 | min-height: 100%; 10 | `; 11 | 12 | const ModalBody = styled.div` 13 | height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | overflow: auto; 17 | `; 18 | 19 | const ModalContent = styled.div` 20 | position: relative; 21 | `; 22 | 23 | let nextModalId = 1; 24 | 25 | interface ModalWindowProps { 26 | slideout?: "left" | "right"; 27 | } 28 | 29 | function ModalWindow({ 30 | children, 31 | slideout, 32 | }: React.PropsWithChildren): ReactElement { 33 | const id = useRef(null); 34 | if (!id.current) { 35 | id.current = `overlay-${nextModalId}`; 36 | nextModalId += 1; 37 | } 38 | 39 | // Open state 40 | // On first render this is false, then it immediate turns to true 41 | // This triggers the opening animation 42 | // Note: add a 50ms delay as sometimes it loads too fast for React to notice 43 | const [open, setOpen] = React.useState(false); 44 | React.useEffect(() => { 45 | setTimeout(() => setOpen(true), 50); 46 | }, []); 47 | 48 | const { requestClose, closeRequested, onCloseCompleted } = 49 | React.useContext(OverlayContext); 50 | 51 | // Closing state 52 | const [closing, setClosing] = React.useState(false); 53 | React.useEffect(() => { 54 | if (closing) { 55 | const timeout = setTimeout(onCloseCompleted, 200); 56 | 57 | return () => { 58 | clearTimeout(timeout); 59 | }; 60 | } 61 | 62 | return () => {}; 63 | }); 64 | 65 | // If parent component requests close, then close. 66 | React.useEffect(() => { 67 | if (closeRequested) { 68 | setClosing(true); 69 | } 70 | }, [closeRequested]); 71 | 72 | React.useEffect(() => { 73 | const keydownEventListener = (e: KeyboardEvent) => { 74 | // Close modal on click escape 75 | if (e.key === "Escape") { 76 | e.preventDefault(); 77 | requestClose(); 78 | } 79 | }; 80 | 81 | document.addEventListener("keydown", keydownEventListener); 82 | 83 | return () => { 84 | document.removeEventListener("keydown", keydownEventListener); 85 | }; 86 | }); 87 | 88 | // Prevent scroll of body when modal is open 89 | React.useEffect(() => { 90 | document.body.style.overflow = "hidden"; 91 | 92 | return () => { 93 | document.body.style.overflow = "auto"; 94 | }; 95 | }, []); 96 | 97 | const body = ( 98 | 99 | 100 | {children} 101 | 102 | 103 | ); 104 | 105 | if (slideout) { 106 | return ( 107 | 120 | {body} 121 | 122 | ); 123 | } else { 124 | return ( 125 | 126 | 135 | {body} 136 | 137 | 138 | ); 139 | } 140 | } 141 | 142 | export default ModalWindow; 143 | -------------------------------------------------------------------------------- /client/src/components/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import GlobalStyles from "@mui/joy/GlobalStyles"; 3 | import Box from "@mui/joy/Box"; 4 | import Divider from "@mui/joy/Divider"; 5 | import List from "@mui/joy/List"; 6 | import ListItem from "@mui/joy/ListItem"; 7 | import ListItemButton, { listItemButtonClasses } from "@mui/joy/ListItemButton"; 8 | import ListItemContent from "@mui/joy/ListItemContent"; 9 | import Typography from "@mui/joy/Typography"; 10 | import Sheet from "@mui/joy/Sheet"; 11 | import HomeRoundedIcon from "@mui/icons-material/HomeRounded"; 12 | import DashboardRoundedIcon from "@mui/icons-material/DashboardRounded"; 13 | import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; 14 | 15 | import ColorSchemeToggle from "./ColorSchemeToggle"; 16 | import { closeSidebar } from "../utils"; 17 | import { NavigationContext } from "@django-bridge/react"; 18 | 19 | export default function Sidebar() { 20 | const { navigate: doNavigate } = React.useContext(NavigationContext); 21 | 22 | const navigate = React.useCallback( 23 | (path: string) => { 24 | closeSidebar(); 25 | doNavigate(path); 26 | }, 27 | [doNavigate] 28 | ); 29 | 30 | return ( 31 | 58 | ({ 60 | ":root": { 61 | "--Sidebar-width": "220px", 62 | [theme.breakpoints.up("lg")]: { 63 | "--Sidebar-width": "240px", 64 | }, 65 | }, 66 | })} 67 | /> 68 | closeSidebar()} 86 | /> 87 | 88 | {/* 89 | 90 | */} 91 | 92 | Djangopress 93 | 94 | 95 | 96 | 108 | theme.vars.radius.sm, 114 | }} 115 | > 116 | 117 | navigate("/")}> 118 | 119 | 120 | Home 121 | 122 | 123 | 124 | 125 | 126 | navigate("/posts/")}> 127 | 128 | 129 | Posts 130 | 131 | 132 | 133 | 134 | 135 | navigate("/media/")}> 136 | 137 | 138 | Media 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /client/src/components/widgets/BlockNoteEditor.css: -------------------------------------------------------------------------------- 1 | @media (min-width: 900px) { 2 | .bn-editor { 3 | padding-inline: 0; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/components/widgets/BlockNoteEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DirtyFormMarker } from "@django-bridge/react"; 3 | import "@blocknote/core/fonts/inter.css"; 4 | import { Block } from "@blocknote/core"; 5 | import { useCreateBlockNote } from "@blocknote/react"; 6 | import { 7 | BlockNoteView, 8 | Theme, 9 | darkDefaultTheme, 10 | lightDefaultTheme, 11 | } from "@blocknote/mantine"; 12 | import "@blocknote/mantine/style.css"; 13 | import "./BlockNoteEditor.css"; 14 | 15 | const lightTheme = { 16 | ...lightDefaultTheme, 17 | colors: { 18 | ...lightDefaultTheme.colors, 19 | editor: { 20 | text: "inherit", 21 | background: "none", 22 | }, 23 | }, 24 | borderRadius: 0, 25 | fontFamily: "inherit", 26 | } satisfies Theme; 27 | 28 | const darkTheme = { 29 | ...darkDefaultTheme, 30 | colors: { 31 | ...lightDefaultTheme.colors, 32 | editor: { 33 | text: "inherit", 34 | background: "none", 35 | }, 36 | }, 37 | borderRadius: 0, 38 | fontFamily: "inherit", 39 | } satisfies Theme; 40 | 41 | interface BlockNoteEditorProps { 42 | id: string; 43 | name: string; 44 | disabled: boolean; 45 | initialContent: Block[]; 46 | } 47 | 48 | export default function BlockNoteEditor({ 49 | name, 50 | initialContent, 51 | }: BlockNoteEditorProps) { 52 | const [blocks, setBlocks] = React.useState(initialContent); 53 | const [dirty, setDirty] = React.useState(false); 54 | const editor = useCreateBlockNote({ initialContent }); 55 | 56 | return ( 57 | <> 58 | 59 | {dirty && } 60 | { 63 | setBlocks(editor.document); 64 | setDirty(true); 65 | }} 66 | theme={{ 67 | light: lightTheme, 68 | dark: darkTheme, 69 | }} 70 | /> 71 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /client/src/components/widgets/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement, useState, Ref, useEffect, useRef } from "react"; 2 | import styled from "styled-components"; 3 | import { FormWidgetChangeNotificationContext } from "@django-bridge/react"; 4 | import Button from "@mui/joy/Button"; 5 | import DeleteIcon from "@mui/icons-material/Delete"; 6 | import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded"; 7 | 8 | // @see https://github.com/facebook/react/issues/24722 9 | function useForwardRef(forwardedRef: Ref) { 10 | // final ref that will share value with forward ref. this is the one we will attach to components 11 | const innerRef = useRef(null); 12 | 13 | useEffect(() => { 14 | // try to share current ref value with forwarded ref 15 | if (!forwardedRef) { 16 | return; 17 | } 18 | if (typeof forwardedRef === "function") { 19 | forwardedRef(innerRef.current); 20 | } else { 21 | // by default forwardedRef.current is readonly 22 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 23 | // @ts-ignore 24 | forwardedRef.current = innerRef.current; 25 | } 26 | }, [forwardedRef]); 27 | 28 | return innerRef; 29 | } 30 | 31 | const StyledFileInput = styled.div` 32 | display: flex; 33 | flex-direction: column; 34 | width: 100%; 35 | justify-content: center; 36 | align-items: center; 37 | font-size: 1rem; 38 | 39 | &:disabled { 40 | background-color: hsl(0, 0%, 95%); 41 | } 42 | 43 | &:focus { 44 | border: 1px solid var(--joy-palette-primary-solidBg); 45 | outline: 1px solid var(--joy-palette-primary-solidBg); 46 | } 47 | `; 48 | 49 | const FileUploadPlaceholder = styled.div` 50 | display: flex; 51 | flex-direction: column; 52 | align-items: center; 53 | gap: 1rem; 54 | border: 2px dashed #e0e0e0; 55 | padding: 2rem 1rem; 56 | width: 100%; 57 | border-radius: 0.5rem; 58 | &.active { 59 | background: #f3f6fa; 60 | border: 2px dashed var(--joy-palette-primary-solidBg); 61 | } 62 | .field-has-error & { 63 | border: 1px solid #d9303e; 64 | } 65 | `; 66 | 67 | const UploadedFiles = styled.ul` 68 | width: 100%; 69 | li { 70 | display: flex; 71 | align-items: center; 72 | gap: 1rem; 73 | padding: 0.5rem 1rem; 74 | border: 1px solid #e0e0e0; 75 | border-radius: 0.5rem; 76 | margin-bottom: 1rem; 77 | } 78 | `; 79 | 80 | interface FileInputProps extends React.InputHTMLAttributes { 81 | maxFileSizeDisplay?: string; 82 | } 83 | 84 | const FileInput = React.forwardRef( 85 | ( 86 | { 87 | onChange: originalOnChange, 88 | maxFileSizeDisplay, 89 | ...props 90 | }: FileInputProps, 91 | ref 92 | ): ReactElement => { 93 | // Format allowed extensions (eg, ".pdf, .docx or .txt") 94 | let allowedExtensionsDisplay = ""; 95 | if (props.accept) { 96 | const allowedExtensions = props.accept?.split(",") || []; 97 | allowedExtensionsDisplay = `${allowedExtensions 98 | .slice(undefined, -1) 99 | .join(", ")} or ${allowedExtensions.slice(-1).join("")}`; 100 | } 101 | 102 | const [files, setFiles] = useState(null); 103 | const [dragActive, setDragActive] = useState(false); 104 | const forwardedRef = useForwardRef(ref); 105 | const changeNotification = React.useContext( 106 | FormWidgetChangeNotificationContext 107 | ); 108 | 109 | const handleDrag = (e: React.DragEvent) => { 110 | e.preventDefault(); 111 | e.stopPropagation(); 112 | if (e.type === "dragenter" || e.type === "dragover") { 113 | setDragActive(true); 114 | } else if (e.type === "dragleave") { 115 | setDragActive(false); 116 | } 117 | }; 118 | 119 | const handleDragOver = (e: React.DragEvent) => { 120 | e.preventDefault(); 121 | e.stopPropagation(); 122 | }; 123 | 124 | const handleDrop = (e: React.DragEvent) => { 125 | e.preventDefault(); 126 | e.stopPropagation(); 127 | const uploadedFiles = e.dataTransfer.files; 128 | setFiles(uploadedFiles); 129 | 130 | // update file input 131 | const fileInput = forwardedRef.current; 132 | if (fileInput) { 133 | fileInput.files = uploadedFiles; 134 | } 135 | }; 136 | 137 | return ( 138 | 144 | {files && files.length > 0 ? ( 145 | 146 | {Array.from(files).map((file, idx) => ( 147 |
  • 148 | {/* */} 149 |
    150 |
    {file.name}
    151 | {file.size} 152 |
    153 | 170 |
  • 171 | ))} 172 |
    173 | ) : ( 174 | 175 | 176 |

    177 | Drag and drop your file or{" "} 178 | 191 |

    192 | 193 | {allowedExtensionsDisplay && ( 194 | <>File must be in {allowedExtensionsDisplay} format. 195 | )} 196 | {maxFileSizeDisplay && <> Max file size {maxFileSizeDisplay}.} 197 | 198 |
    199 | )} 200 | { 206 | e.currentTarget.value = ""; 207 | }} 208 | onChange={(e) => { 209 | setFiles(e.target.files); 210 | if (originalOnChange) { 211 | originalOnChange(e); 212 | } 213 | changeNotification(); 214 | }} 215 | {...props} 216 | /> 217 |
    218 | ); 219 | } 220 | ); 221 | 222 | export default FileInput; 223 | -------------------------------------------------------------------------------- /client/src/components/widgets/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import InputProps from "@mui/joy/Input/InputProps"; 2 | import { SxProps } from "@mui/joy/styles/types"; 3 | import Input from "@mui/joy/Input"; 4 | import { DirtyFormMarker } from "@django-bridge/react"; 5 | import React from "react"; 6 | 7 | export interface TextInputProps extends InputProps { 8 | avariant: "default" | "large"; 9 | } 10 | 11 | export default function TextInput({ 12 | avariant, 13 | onChange: originalOnChange, 14 | ...props 15 | }: TextInputProps) { 16 | const [dirty, setDirty] = React.useState(false); 17 | 18 | let sx: SxProps = props.sx || {}; 19 | if (avariant === "large") { 20 | sx = { 21 | ...sx, 22 | border: "none", 23 | boxShadow: "none", 24 | background: "none", 25 | fontSize: { xs: "30px", sm: "30px", md: "48px" }, 26 | fontWeight: 700, 27 | }; 28 | } 29 | 30 | const onChange = React.useCallback( 31 | (e: React.ChangeEvent) => { 32 | setDirty(true); 33 | 34 | if (originalOnChange) { 35 | originalOnChange(e); 36 | } 37 | }, 38 | [originalOnChange] 39 | ); 40 | 41 | return ( 42 | <> 43 | {dirty && } 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /client/src/contexts.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const CSRFTokenContext = React.createContext(""); 4 | 5 | export interface URLs { 6 | posts_index: string; 7 | media_index: string; 8 | } 9 | 10 | export const URLsContext = React.createContext({ 11 | posts_index: "", 12 | media_index: "", 13 | }); 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/src/deserializers/Field.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { WidgetDef } from "./widgets/base"; 3 | import Field, { FieldProps } from "../components/Field"; 4 | 5 | export default class FieldDef { 6 | name: string; 7 | 8 | label: string; 9 | 10 | required: boolean; 11 | 12 | disabled: boolean; 13 | 14 | widget: WidgetDef; 15 | 16 | helpText: string; 17 | 18 | value: string; 19 | 20 | constructor( 21 | name: string, 22 | label: string, 23 | required: boolean, 24 | disabled: boolean, 25 | widget: WidgetDef, 26 | helpText: string, 27 | value: string, 28 | ) { 29 | this.name = name; 30 | this.label = label; 31 | this.required = required; 32 | this.disabled = disabled; 33 | this.widget = widget; 34 | this.helpText = helpText; 35 | this.value = value; 36 | } 37 | 38 | render( 39 | errors: string[], 40 | displayOptions: FieldProps["displayOptions"] = {}, 41 | ): ReactElement { 42 | return ( 43 | 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/src/deserializers/Form.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import FieldDef from "./Field"; 3 | import Alert from "@mui/joy/Alert"; 4 | import WarningIcon from "@mui/icons-material/Warning"; 5 | 6 | export interface Tab { 7 | label: string; 8 | fields: string[]; 9 | errorCount: number; 10 | } 11 | 12 | export function getInitialTab(tabs: Tab[]): Tab { 13 | return tabs.find((tab) => tab.errorCount > 0) || tabs[0]; 14 | } 15 | 16 | interface FormRenderOptions { 17 | hideRequiredAsterisks?: boolean; 18 | } 19 | export default class FormDef { 20 | fields: FieldDef[]; 21 | 22 | errors: { [field: string]: string[] }; 23 | 24 | constructor(fields: FieldDef[], errors: FormDef["errors"]) { 25 | this.fields = fields; 26 | this.errors = errors; 27 | } 28 | 29 | render(renderOptions: FormRenderOptions = {}): ReactElement { 30 | // eslint-disable-next-line no-underscore-dangle 31 | const formErrors = this.errors.__all__; 32 | 33 | return ( 34 | <> 35 | {!!formErrors && ( 36 | 37 |
      38 | {formErrors.map((error) => ( 39 |
    • 40 | {error} 41 |
    • 42 | ))} 43 |
    44 |
    45 | )} 46 | {this.fields.map((field, fieldIndex) => ( 47 |
    48 | {field.render(this.errors[field.name] || [], { 49 | focusOnMount: fieldIndex === 0, 50 | hideRequiredAsterisk: renderOptions.hideRequiredAsterisks, 51 | })} 52 |
    53 | ))} 54 | 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /client/src/deserializers/ServerRenderedField.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import Field, { FieldProps } from "../components/Field"; 3 | 4 | export default class ServerRemderedFieldDef { 5 | name: string; 6 | 7 | label: string; 8 | 9 | required: boolean; 10 | 11 | disabled: boolean; 12 | 13 | helpText: string; 14 | 15 | html: string; 16 | 17 | constructor( 18 | name: string, 19 | label: string, 20 | required: boolean, 21 | disabled: boolean, 22 | helpText: string, 23 | html: string, 24 | ) { 25 | this.name = name; 26 | this.label = label; 27 | this.required = required; 28 | this.disabled = disabled; 29 | this.helpText = helpText; 30 | this.html = html; 31 | } 32 | 33 | render( 34 | errors: string[], 35 | displayOptions: FieldProps["displayOptions"] = {}, 36 | ): ReactElement { 37 | return ( 38 | } 45 | helpText={this.helpText} 46 | displayOptions={displayOptions} 47 | errors={errors} 48 | /> 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /client/src/deserializers/widgets/BlockNoteEditor.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { WidgetDef } from "./base"; 3 | import BlockNoteEditor from "../../components/widgets/BlockNoteEditor"; 4 | 5 | export default class BlockNoteEditorDef implements WidgetDef { 6 | 7 | render( 8 | id: string, 9 | name: string, 10 | disabled: boolean, 11 | value: string, 12 | ): ReactElement { 13 | return ( 14 | 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/deserializers/widgets/FileInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import FileInput from "../../components/widgets/FileInput"; 3 | import { WidgetDef } from "./base"; 4 | 5 | export default class FileInputDef implements WidgetDef { 6 | className: string; 7 | 8 | accept: string; 9 | 10 | maxFileSizeDisplay: string; 11 | 12 | constructor( 13 | className: string, 14 | accept: string, 15 | maxFileSizeDisplay: string, 16 | ) { 17 | this.className = className; 18 | this.accept = accept; 19 | this.maxFileSizeDisplay = maxFileSizeDisplay; 20 | } 21 | 22 | render( 23 | id: string, 24 | name: string, 25 | disabled: boolean, 26 | value: string, 27 | ): ReactElement { 28 | return ( 29 | 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /client/src/deserializers/widgets/Select.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { WidgetDef } from "./base"; 3 | import Select from "@mui/joy/Select"; 4 | import Option from "@mui/joy/Option"; 5 | 6 | export default class SelectDef implements WidgetDef { 7 | choices: { label: string; value: string }[]; 8 | 9 | className: string; 10 | 11 | constructor( 12 | choices: { label: string; value: string }[], 13 | className: string, 14 | ) { 15 | this.choices = choices; 16 | this.className = className; 17 | } 18 | 19 | render( 20 | id: string, 21 | name: string, 22 | disabled: boolean, 23 | value: string, 24 | ): ReactElement { 25 | // Find the default value 26 | let defaultValue = null; 27 | this.choices.forEach((choice) => { 28 | // Cast null value to and empty string as that's that value Django uses for the empty value of a ModelChoiceField 29 | // Also cast ints to string as Django may use both interchangably for model primary keys 30 | if (`${choice.value}` === `${value || ""}`) { 31 | defaultValue = choice.value; 32 | } 33 | }); 34 | 35 | return ( 36 | <> 37 | 40 | 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/deserializers/widgets/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | import { WidgetDef } from "./base"; 3 | import TextInput from "../../components/widgets/TextInput"; 4 | 5 | export default class TextInputDef implements WidgetDef { 6 | type: "text" | "email" | "url" | "password"; 7 | 8 | variant: "default" | "large"; 9 | 10 | constructor( 11 | type: TextInputDef["type"], 12 | variant: TextInputDef["variant"], 13 | ) { 14 | this.type = type; 15 | this.variant = variant; 16 | } 17 | 18 | render( 19 | id: string, 20 | name: string, 21 | disabled: boolean, 22 | value: string, 23 | ): ReactElement { 24 | return ( 25 | 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/src/deserializers/widgets/base.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from "react"; 2 | 3 | export interface WidgetDef { 4 | render( 5 | id: string, 6 | name: string, 7 | disabled: boolean, 8 | value: string, 9 | ): ReactElement; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import * as DjangoBridge from "@django-bridge/react"; 4 | 5 | import LoginView from "./views/Login"; 6 | import HomeView from "./views/Home"; 7 | import ConfirmDeleteView from "./views/ConfirmDelete"; 8 | import PostIndexView from "./views/PostIndex"; 9 | import MediaIndexView from "./views/MediaIndex"; 10 | import PostFormView from "./views/PostForm"; 11 | import MediaFormView from "./views/MediaForm"; 12 | 13 | import FormDef from "./deserializers/Form"; 14 | import FieldDef from "./deserializers/Field"; 15 | import ServerRenderedFieldDef from "./deserializers/ServerRenderedField"; 16 | import TextInputDef from "./deserializers/widgets/TextInput"; 17 | import SelectDef from "./deserializers/widgets/Select"; 18 | import FileInputDef from "./deserializers/widgets/FileInput"; 19 | import BlockNoteEditorDef from "./deserializers/widgets/BlockNoteEditor"; 20 | import { CSRFTokenContext, URLsContext } from "./contexts"; 21 | 22 | const config = new DjangoBridge.Config(); 23 | 24 | // Add your views here 25 | config.addView("Login", LoginView); 26 | config.addView("Home", HomeView); 27 | config.addView("ConfirmDelete", ConfirmDeleteView); 28 | config.addView("PostIndex", PostIndexView); 29 | config.addView("PostForm", PostFormView); 30 | config.addView("MediaIndex", MediaIndexView); 31 | config.addView("MediaForm", MediaFormView); 32 | 33 | // Add your context providers here 34 | config.addContextProvider("csrf_token", CSRFTokenContext); 35 | config.addContextProvider("urls", URLsContext); 36 | 37 | // Add your deserializers here 38 | config.addAdapter("forms.Form", FormDef); 39 | config.addAdapter("forms.Field", FieldDef); 40 | config.addAdapter("forms.ServerRenderedField", ServerRenderedFieldDef); 41 | config.addAdapter("forms.TextInput", TextInputDef); 42 | config.addAdapter("forms.Select", SelectDef); 43 | config.addAdapter("forms.FileInput", FileInputDef); 44 | config.addAdapter("forms.BlockNoteEditor", BlockNoteEditorDef); 45 | 46 | const rootElement = document.getElementById("root")!; 47 | const initialResponse = JSON.parse( 48 | document.getElementById("initial-response")!.textContent! 49 | ); 50 | 51 | ReactDOM.createRoot(rootElement).render( 52 | 53 | 54 | 55 | ); 56 | -------------------------------------------------------------------------------- /client/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Post { 2 | title: string; 3 | edit_url: string 4 | } 5 | -------------------------------------------------------------------------------- /client/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function openSidebar() { 2 | if (typeof document !== "undefined") { 3 | document.body.style.overflow = "hidden"; 4 | document.documentElement.style.setProperty("--SideNavigation-slideIn", "1"); 5 | } 6 | } 7 | 8 | export function closeSidebar() { 9 | if (typeof document !== "undefined") { 10 | document.documentElement.style.removeProperty("--SideNavigation-slideIn"); 11 | document.body.style.removeProperty("overflow"); 12 | } 13 | } 14 | 15 | export function toggleSidebar() { 16 | if (typeof window !== "undefined" && typeof document !== "undefined") { 17 | const slideIn = window 18 | .getComputedStyle(document.documentElement) 19 | .getPropertyValue("--SideNavigation-slideIn"); 20 | if (slideIn) { 21 | closeSidebar(); 22 | } else { 23 | openSidebar(); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/src/views/ConfirmDelete.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Button from "@mui/joy/Button"; 3 | import Box from "@mui/joy/Box"; 4 | import { Form, OverlayContext } from "@django-bridge/react"; 5 | 6 | import Layout from "../components/Layout"; 7 | import { CSRFTokenContext } from "../contexts"; 8 | 9 | interface ConfirmDeleteViewContext { 10 | objectName: string; 11 | messageHtml?: string; 12 | actionUrl: string; 13 | } 14 | 15 | function ConfirmDeleteView({ 16 | objectName, 17 | messageHtml, 18 | actionUrl, 19 | }: ConfirmDeleteViewContext) { 20 | const { overlay, requestClose } = React.useContext(OverlayContext); 21 | const csrfToken = React.useContext(CSRFTokenContext); 22 | 23 | return ( 24 | 25 | {messageHtml && ( 26 | 30 | )} 31 | 32 |
    33 | 34 | 35 | 36 | 37 | {overlay && ( 38 | 45 | )} 46 | 47 |
    48 |
    49 | ); 50 | } 51 | export default ConfirmDeleteView; 52 | -------------------------------------------------------------------------------- /client/src/views/Home.tsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import Layout from "../components/Layout"; 3 | 4 | const HomeWrapper = styled.div` 5 | padding: 20px; 6 | 7 | h1 { 8 | font-weight: 700; 9 | font-size: 1.5em; 10 | margin-bottom: 2em; 11 | } 12 | 13 | p { 14 | margin-bottom: 1em; 15 | max-width: 600px; 16 | line-height: 1.5em; 17 | } 18 | 19 | b { 20 | font-weight: 700; 21 | } 22 | `; 23 | 24 | export default function HomeView() { 25 | return ( 26 | 27 | 28 |

    29 | This is a very basic example of an application built with Django and 30 | React using{" "} 31 | 32 | Django Bridge 33 | {" "} 34 | to connect them. It will eventually be a simple blogging app. 35 |

    36 |

    37 | This application is backed by Django views which provide the data and 38 | perform operations. The frontend is rendered with React using 39 | components from{" "} 40 | 41 | MUI 42 | 43 | . 44 |

    45 |

    46 | Please browse around and have a look at the requests being made in 47 | your network tab to get an idea of how Django Bridge is fetching the data 48 | from Django. 49 |

    50 |

    51 | 52 | 53 | See the source code 54 | 55 | 56 |

    57 |
    58 |
    59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /client/src/views/Login.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from "react"; 2 | import { Form } from "@django-bridge/react"; 3 | import styled from "styled-components"; 4 | import { CssVarsProvider } from "@mui/joy/styles"; 5 | import CssBaseline from "@mui/joy/CssBaseline"; 6 | import { Button, Typography } from "@mui/joy"; 7 | 8 | import { CSRFTokenContext } from "../contexts"; 9 | import FormDef from "../deserializers/Form"; 10 | 11 | const Wrapper = styled.div` 12 | display: flex; 13 | flex-flow: column; 14 | justify-content: center; 15 | align-items: center; 16 | background-color: #00246b; 17 | width: 100vw; 18 | height: 100vh; 19 | `; 20 | 21 | const LoginWrapper = styled.div` 22 | width: 24rem; 23 | border-radius: 0.5rem; 24 | padding: 2.5rem; 25 | background-color: white; 26 | 27 | h2 { 28 | color: #333; 29 | font-weight: 800; 30 | font-size: 1.5rem; 31 | line-height: 2rem; 32 | } 33 | `; 34 | 35 | const AlternativeSignIn = styled.div` 36 | padding: 1rem; 37 | padding-bottom: 1.5rem; 38 | margin-bottom: 1rem; 39 | margin-top: 1rem; 40 | display: flex; 41 | justify-content: center; 42 | border-bottom: 1px solid var(--joy-palette-neutral-300); 43 | 44 | a:hover { 45 | text-decoration: none; 46 | } 47 | `; 48 | 49 | const Space = styled.div` 50 | flex-grow: 1; 51 | `; 52 | 53 | const SubmitButtonWrapper = styled.div` 54 | display: flex; 55 | justify-content: center; 56 | margin-top: 1rem; 57 | `; 58 | 59 | interface LoginViewContext { 60 | form: FormDef; 61 | actionUrl: string; 62 | tempActionUrl: string | null; 63 | } 64 | 65 | function LoginView({ 66 | form, 67 | actionUrl, 68 | tempActionUrl, 69 | }: LoginViewContext): ReactElement { 70 | const csrfToken = React.useContext(CSRFTokenContext); 71 | 72 | return ( 73 | 74 | 75 | 76 | 77 | 78 | 79 | Sign in to Djangopress 80 | 81 | 82 | {tempActionUrl && ( 83 | 84 |
    85 | 90 | 91 |
    92 |
    93 | )} 94 | 95 |
    96 | 97 | 98 | {form.render({ hideRequiredAsterisks: true })} 99 | 100 | 101 | 102 | 103 |
    104 |
    105 | 106 |
    107 |
    108 | ); 109 | } 110 | export default LoginView; 111 | -------------------------------------------------------------------------------- /client/src/views/MediaForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Box from "@mui/joy/Box"; 3 | import Button from "@mui/joy/Button"; 4 | import { Form, OverlayContext } from "@django-bridge/react"; 5 | import FormDef from "../deserializers/Form"; 6 | import Layout from "../components/Layout"; 7 | import { CSRFTokenContext, URLsContext } from "../contexts"; 8 | 9 | interface MediaFormViewProps { 10 | title: string; 11 | submit_button_label: string; 12 | action_url: string; 13 | form: FormDef; 14 | } 15 | 16 | export default function MediaFormView({ 17 | title, 18 | submit_button_label, 19 | action_url, 20 | form, 21 | }: MediaFormViewProps) { 22 | const { overlay, requestClose } = React.useContext(OverlayContext); 23 | const csrf_token = React.useContext(CSRFTokenContext); 24 | const urls = React.useContext(URLsContext); 25 | 26 | return ( 27 | 31 |
    32 | 33 | 34 | {form.render()} 35 | 36 | 37 | 38 | {overlay && ( 39 | 46 | )} 47 | 48 |
    49 |
    50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /client/src/views/MediaIndex.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import styled from "styled-components"; 3 | import FileUploadIcon from '@mui/icons-material/FileUpload'; 4 | import Button from "@mui/joy/Button"; 5 | import { showOpenFilePicker } from "show-open-file-picker"; 6 | 7 | import Layout from "../components/Layout"; 8 | import { Link, NavigationContext } from "@django-bridge/react"; 9 | import { CSRFTokenContext } from "../contexts"; 10 | 11 | const MAX_UPLOAD_SIZE = 4 * 1024 * 1024; // 4MB 12 | 13 | function readFile(file: File): Promise { 14 | return new Promise((resolve) => { 15 | const reader = new FileReader(); 16 | 17 | reader.onload = async (e) => { 18 | if (!e.target?.result) { 19 | return null; 20 | } 21 | 22 | resolve(new Uint8Array(e.target.result as ArrayBuffer)); 23 | }; 24 | 25 | reader.readAsArrayBuffer(file); 26 | }); 27 | } 28 | 29 | async function uploadFile( 30 | file: File, 31 | uploadUrl: string, 32 | csrfToken: string, 33 | ){ 34 | const fileData = await readFile(file); 35 | 36 | if (fileData.length > MAX_UPLOAD_SIZE) { 37 | throw "File size too large"; 38 | return; 39 | } 40 | 41 | // Send file 42 | const formData = new FormData(); 43 | formData.append("csrfmiddlewaretoken", csrfToken); 44 | formData.append("title", file.name); 45 | formData.append( 46 | "file", 47 | new Blob([fileData], { 48 | type: "application/octet-stream", 49 | }), 50 | file.name, 51 | ); 52 | 53 | const response = await fetch(uploadUrl, { 54 | method: "POST", 55 | body: formData, 56 | }); 57 | 58 | if (!response.ok) { 59 | throw "Response from server was not OK"; 60 | } 61 | } 62 | 63 | const MediaAssetListing = styled.ul` 64 | display: grid; 65 | grid-template-columns: repeat(auto-fill, minmax(125px, 1fr)); 66 | grid-gap: 20px; 67 | list-style: none; 68 | margin: 20px; 69 | 70 | li { 71 | display: flex; 72 | flex-flow: column; 73 | align-items: center; 74 | justify-content: center; 75 | } 76 | 77 | figure { 78 | display: flex; 79 | flex-flow: column; 80 | align-items: center; 81 | } 82 | 83 | figcaption { 84 | margin-top: 10px; 85 | } 86 | `; 87 | 88 | interface MediaIndexViewProps { 89 | assets: { 90 | id: number; 91 | title: string; 92 | edit_url: string; 93 | thumbnail_url: string | null; 94 | }[]; 95 | upload_url: string; 96 | } 97 | 98 | export default function MediaIndexView({ assets, upload_url }: MediaIndexViewProps) { 99 | const { refreshProps } = React.useContext(NavigationContext); 100 | const csrfToken = React.useContext(CSRFTokenContext); 101 | 102 | return ( 103 | ( 107 | 135 | )} 136 | > 137 | 138 | {assets.map((asset) => ( 139 |
  • 140 | 141 |
    142 | {asset.title} 143 |
    {asset.title}
    144 |
    145 | 146 |
  • 147 | ))} 148 |
    149 |
    150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /client/src/views/PostForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Button from "@mui/joy/Button"; 3 | import Box from "@mui/joy/Box"; 4 | import { Form, OverlayContext } from "@django-bridge/react"; 5 | import FormDef from "../deserializers/Form"; 6 | import { Post } from "../types"; 7 | import Layout from "../components/Layout"; 8 | import { CSRFTokenContext, URLsContext } from "../contexts"; 9 | 10 | interface PostFormViewProps { 11 | post: Post | null; 12 | action_url: string; 13 | form: FormDef; 14 | } 15 | 16 | export default function PostFormView({ 17 | post, 18 | action_url, 19 | form, 20 | }: PostFormViewProps) { 21 | const { overlay, requestClose } = React.useContext(OverlayContext); 22 | const csrf_token = React.useContext(CSRFTokenContext); 23 | const urls = React.useContext(URLsContext); 24 | 25 | return ( 26 | 30 |
    31 | 32 | 33 | {form.render()} 34 | 35 | 36 | 37 | {overlay && ( 38 | 45 | )} 46 | 47 |
    48 |
    49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /client/src/views/PostIndex.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import PostAddIcon from "@mui/icons-material/PostAdd"; 3 | import Table from "@mui/joy/Table"; 4 | import ButtonGroup from "@mui/joy/ButtonGroup"; 5 | import Button from "@mui/joy/Button"; 6 | import IconButton from "@mui/joy/IconButton"; 7 | import Delete from "@mui/icons-material/Delete"; 8 | import Link from "@mui/joy/Link"; 9 | 10 | import Layout from "../components/Layout"; 11 | import { 12 | Link as DjangoBridgeLink, 13 | NavigationContext, 14 | } from "@django-bridge/react"; 15 | import ModalWindow from "../components/ModalWindow"; 16 | 17 | interface Post { 18 | title: string; 19 | edit_url: string; 20 | delete_url: string; 21 | } 22 | 23 | interface PostIndexViewProps { 24 | posts: Post[]; 25 | } 26 | 27 | export default function PostIndexView({ posts }: PostIndexViewProps) { 28 | const { openOverlay, refreshProps } = React.useContext(NavigationContext); 29 | 30 | return ( 31 | ( 35 | 54 | )} 55 | fullWidth 56 | > 57 | td:first-child": { paddingLeft: { xs: 2, md: 6 } }, 60 | "& tr > th:first-child": { paddingLeft: { xs: 2, md: 6 } }, 61 | "& tr > td:last-child": { paddingRight: { xs: 2, md: 6 } }, 62 | "& tr > th:last-child": { paddingRight: { xs: 2, md: 6 } }, 63 | }} 64 | > 65 | 66 | 67 | 68 | 69 | 70 | 71 | {posts.map((post) => ( 72 | 73 | 110 | 111 | ))} 112 | 113 |
    Post
    74 | 79 | {post.title} 80 | 81 | 87 | 88 | Edit 89 | 90 | 92 | openOverlay( 93 | post.delete_url, 94 | (content) => ( 95 | {content} 96 | ), 97 | { 98 | onClose: () => { 99 | // Refresh props so new post pops up in listing 100 | refreshProps(); 101 | }, 102 | } 103 | ) 104 | } 105 | > 106 | 107 | 108 | 109 |
    114 | 115 | {/* */} 116 |
    117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2021", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | base: "/static/", 7 | build: { 8 | manifest: true, 9 | rollupOptions: { 10 | input: "/src/main.tsx", 11 | }, 12 | }, 13 | plugins: [react()], 14 | }); 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | server: 3 | build: 4 | context: . 5 | dockerfile: server/Dockerfile 6 | target: dev 7 | init: true 8 | command: django-admin runserver 0.0.0.0:8000 9 | environment: 10 | DJANGO_SECRET_KEY: secret 11 | DJANGO_DEBUG: "true" 12 | DEMO_MODE: "true" 13 | DJANGO_ALLOWED_HOSTS: "*" 14 | DATABASE_URL: postgres://postgres@postgres/postgres 15 | VITE_SERVER_ORIGIN: http://localhost:5173/static 16 | ports: 17 | - 8000:8000 18 | volumes: 19 | - ./server:/server/ 20 | - ./client:/client/ 21 | depends_on: 22 | postgres: 23 | condition: service_healthy 24 | 25 | client: 26 | build: 27 | context: . 28 | dockerfile: client/Dockerfile 29 | target: dev 30 | init: true 31 | command: npm run dev -- --host 32 | volumes: 33 | - ./client:/client/ 34 | ports: 35 | - 5173:5173 36 | 37 | postgres: 38 | image: postgres:17 39 | environment: 40 | POSTGRES_HOST_AUTH_METHOD: trust 41 | expose: 42 | - 5432 43 | volumes: 44 | - pgdata:/var/lib/postgresql/data 45 | healthcheck: 46 | test: ["CMD-SHELL", "pg_isready -U postgres"] 47 | interval: 5s 48 | timeout: 5s 49 | retries: 5 50 | 51 | volumes: 52 | pgdata: 53 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22.13.1-bookworm-slim AS client 2 | 3 | # Install client dependencies 4 | COPY client/package.json client/package-lock.json /client/ 5 | RUN cd /client && npm install 6 | 7 | # Copy the source code of the client into the container. 8 | COPY client /client 9 | 10 | # Build the client 11 | RUN cd /client && npm run build 12 | 13 | FROM python:3.13.1-slim-bookworm AS base 14 | 15 | RUN apt update -y \ 16 | && apt install -y --no-install-recommends \ 17 | # Required to build psycopg2 18 | build-essential \ 19 | libpq-dev \ 20 | # Required for django-admin dbshell 21 | postgresql-client \ 22 | && rm -rf /var/lib/apt/lists/* 23 | 24 | ENV VIRTUAL_ENV=/venv 25 | ENV PATH=$VIRTUAL_ENV/bin:$PATH \ 26 | PYTHONPATH=/server \ 27 | PYTHONUNBUFFERED=1 \ 28 | DJANGO_SETTINGS_MODULE=djangopress.settings \ 29 | BASE_URL=http://localhost:8000 \ 30 | PORT=8000 31 | 32 | # Add user that will be used in the container 33 | ARG UID=1000 34 | RUN useradd djangopress --uid ${UID} --create-home && mkdir /server $VIRTUAL_ENV && chown -R djangopress /server $VIRTUAL_ENV 35 | 36 | # Install poetry 37 | RUN pip install poetry==1.5.1 38 | 39 | # Use user "djangopress" to run the build commands below and the server itself. 40 | USER djangopress 41 | 42 | # Use /server folder as a directory where the source code is stored. 43 | WORKDIR /server 44 | 45 | # Set up virtual environment 46 | RUN python -m venv $VIRTUAL_ENV 47 | 48 | # Install Python dependencies 49 | COPY --chown=djangopress server/pyproject.toml server/poetry.lock ./ 50 | RUN poetry install --no-root --only main 51 | 52 | # Copy the source code of the project into the container. 53 | COPY --chown=djangopress server . 54 | 55 | # Run poetry install again to install our project 56 | RUN poetry install --only main 57 | 58 | FROM base AS prod 59 | 60 | # Copy the client bundle from the client target 61 | COPY --chown=djangopress --from=client /client/dist /client 62 | 63 | # Collect static files 64 | ENV VITE_BUNDLE_DIR=/client 65 | RUN DJANGO_SECRET_KEY=secret python manage.py collectstatic --noinput --clear 66 | 67 | CMD django-admin migrate && gunicorn -w 4 --threads 2 djangopress.wsgi:application 68 | 69 | FROM base AS dev 70 | 71 | # Install dev dependencies 72 | RUN poetry install 73 | -------------------------------------------------------------------------------- /server/djangopress/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/__init__.py -------------------------------------------------------------------------------- /server/djangopress/adapters.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf import settings 3 | from django.forms.models import ModelChoiceIteratorValue 4 | from django.template.defaultfilters import filesizeformat 5 | from django_bridge.adapters import Adapter, register 6 | from telepath import ValueNode 7 | 8 | from .widgets import BlockNoteEditor 9 | 10 | 11 | class TextInputAdapter(Adapter): 12 | js_constructor = "forms.TextInput" 13 | 14 | def js_args(self, widget): 15 | return [ 16 | "text", 17 | widget.attrs.get("variant", "default"), 18 | ] 19 | 20 | 21 | register(TextInputAdapter(), forms.TextInput) 22 | 23 | 24 | class PasswordInputAdapter(Adapter): 25 | js_constructor = "forms.TextInput" 26 | 27 | def js_args(self, widget): 28 | return [ 29 | "password", 30 | widget.attrs.get("variant", "default"), 31 | ] 32 | 33 | 34 | register(PasswordInputAdapter(), forms.PasswordInput) 35 | 36 | 37 | class FileInputAdapter(Adapter): 38 | js_constructor = "forms.FileInput" 39 | 40 | def js_args(self, widget): 41 | return [ 42 | widget.attrs.get("class", ""), 43 | widget.attrs.get("accept", ""), 44 | filesizeformat(settings.MAX_UPLOAD_SIZE), 45 | ] 46 | 47 | 48 | register(FileInputAdapter(), forms.FileInput) 49 | 50 | 51 | class SelectAdapter(Adapter): 52 | js_constructor = "forms.Select" 53 | 54 | def js_args(self, widget): 55 | return [ 56 | widget.options("__NAME__", ""), 57 | widget.attrs.get("class", ""), 58 | ] 59 | 60 | 61 | register(SelectAdapter(), forms.Select) 62 | 63 | 64 | class BlockNoteEditorAdapter(Adapter): 65 | js_constructor = "forms.BlockNoteEditor" 66 | 67 | def js_args(self, widget): 68 | return [] 69 | 70 | 71 | register(BlockNoteEditorAdapter(), BlockNoteEditor) 72 | -------------------------------------------------------------------------------- /server/djangopress/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for djangopress project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangopress.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /server/djangopress/auth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/auth/__init__.py -------------------------------------------------------------------------------- /server/djangopress/auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthConfig(AppConfig): 5 | name = "djangopress.auth" 6 | label = "djangopress_auth" 7 | -------------------------------------------------------------------------------- /server/djangopress/auth/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.1 on 2024-01-10 22:53 2 | 3 | import django.contrib.auth.models 4 | import django.contrib.auth.validators 5 | import django.utils.timezone 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | ("auth", "0012_alter_user_first_name_max_length"), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="User", 19 | fields=[ 20 | ( 21 | "id", 22 | models.BigAutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("password", models.CharField(max_length=128, verbose_name="password")), 30 | ( 31 | "last_login", 32 | models.DateTimeField( 33 | blank=True, null=True, verbose_name="last login" 34 | ), 35 | ), 36 | ( 37 | "is_superuser", 38 | models.BooleanField( 39 | default=False, 40 | help_text="Designates that this user has all permissions without explicitly assigning them.", 41 | verbose_name="superuser status", 42 | ), 43 | ), 44 | ( 45 | "username", 46 | models.CharField( 47 | error_messages={ 48 | "unique": "A user with that username already exists." 49 | }, 50 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", 51 | max_length=150, 52 | unique=True, 53 | validators=[ 54 | django.contrib.auth.validators.UnicodeUsernameValidator() 55 | ], 56 | verbose_name="username", 57 | ), 58 | ), 59 | ( 60 | "first_name", 61 | models.CharField( 62 | blank=True, max_length=150, verbose_name="first name" 63 | ), 64 | ), 65 | ( 66 | "last_name", 67 | models.CharField( 68 | blank=True, max_length=150, verbose_name="last name" 69 | ), 70 | ), 71 | ( 72 | "email", 73 | models.EmailField( 74 | blank=True, max_length=254, verbose_name="email address" 75 | ), 76 | ), 77 | ( 78 | "is_staff", 79 | models.BooleanField( 80 | default=False, 81 | help_text="Designates whether the user can log into this admin site.", 82 | verbose_name="staff status", 83 | ), 84 | ), 85 | ( 86 | "is_active", 87 | models.BooleanField( 88 | default=True, 89 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 90 | verbose_name="active", 91 | ), 92 | ), 93 | ( 94 | "date_joined", 95 | models.DateTimeField( 96 | default=django.utils.timezone.now, verbose_name="date joined" 97 | ), 98 | ), 99 | ( 100 | "groups", 101 | models.ManyToManyField( 102 | blank=True, 103 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 104 | related_name="user_set", 105 | related_query_name="user", 106 | to="auth.group", 107 | verbose_name="groups", 108 | ), 109 | ), 110 | ( 111 | "user_permissions", 112 | models.ManyToManyField( 113 | blank=True, 114 | help_text="Specific permissions for this user.", 115 | related_name="user_set", 116 | related_query_name="user", 117 | to="auth.permission", 118 | verbose_name="user permissions", 119 | ), 120 | ), 121 | ], 122 | options={ 123 | "verbose_name": "user", 124 | "verbose_name_plural": "users", 125 | "abstract": False, 126 | }, 127 | managers=[ 128 | ("objects", django.contrib.auth.models.UserManager()), 129 | ], 130 | ), 131 | ] 132 | -------------------------------------------------------------------------------- /server/djangopress/auth/migrations/0002_user_is_temporary.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-16 14:14 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("djangopress_auth", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="is_temporary", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /server/djangopress/auth/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/auth/migrations/__init__.py -------------------------------------------------------------------------------- /server/djangopress/auth/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | class User(AbstractUser): 6 | is_temporary = models.BooleanField(default=False) 7 | -------------------------------------------------------------------------------- /server/djangopress/auth/views.py: -------------------------------------------------------------------------------- 1 | import string 2 | 3 | from django.conf import settings 4 | from django.contrib import messages 5 | from django.contrib.auth import login 6 | from django.contrib.auth.views import LoginView as BaseLoginView 7 | from django.core.exceptions import PermissionDenied 8 | from django.shortcuts import redirect 9 | from django.urls import reverse 10 | from django.utils.crypto import get_random_string 11 | from django.views.decorators.http import require_POST 12 | from django_bridge.views import DjangoBridgeView 13 | 14 | from .models import User 15 | 16 | 17 | class LoginView(DjangoBridgeView, BaseLoginView): 18 | title = "Sign in to Djangopress" 19 | view_name = "Login" 20 | 21 | def form_valid(self, form): 22 | # Add a success message to the next page. 23 | messages.success( 24 | self.request, 25 | "Successfully logged in as {}".format(form.get_user()), 26 | ) 27 | 28 | return super().form_valid(form) 29 | 30 | def get_context_data(self, **kwargs): 31 | context = super().get_context_data(**kwargs) 32 | 33 | return { 34 | "form": context["form"], 35 | "actionUrl": reverse("login"), 36 | "tempActionUrl": reverse("login_temporary") if settings.DEMO_MODE else None, 37 | } 38 | 39 | 40 | @require_POST 41 | def login_temporary(request): 42 | if not settings.DEMO_MODE: 43 | raise PermissionDenied("DEMO_MODE is not enabled") 44 | 45 | user = User.objects.create( 46 | username="temp-" + get_random_string(10, allowed_chars=string.ascii_lowercase), 47 | is_temporary=True, 48 | ) 49 | 50 | login(request, user) 51 | 52 | return redirect("home") 53 | -------------------------------------------------------------------------------- /server/djangopress/context_providers.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | 4 | def urls(request): 5 | return { 6 | "posts_index": reverse("posts_index"), 7 | "media_index": reverse("media_index"), 8 | } 9 | -------------------------------------------------------------------------------- /server/djangopress/media/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/media/__init__.py -------------------------------------------------------------------------------- /server/djangopress/media/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MediaConfig(AppConfig): 5 | name = "djangopress.media" 6 | label = "djangopress_media" 7 | -------------------------------------------------------------------------------- /server/djangopress/media/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Image 4 | 5 | 6 | class UploadForm(forms.ModelForm): 7 | 8 | class Meta: 9 | model = Image 10 | fields = ["title", "file"] 11 | widgets = { 12 | "title": forms.TextInput(), 13 | } 14 | 15 | 16 | class EditForm(forms.ModelForm): 17 | 18 | class Meta: 19 | model = Image 20 | fields = ["title"] 21 | widgets = { 22 | "title": forms.TextInput(), 23 | } 24 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.1 on 2024-01-10 22:53 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [ 11 | ("contenttypes", "0002_remove_content_type_name"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="MediaAsset", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("title", models.TextField()), 28 | ( 29 | "status", 30 | models.CharField( 31 | choices=[("draft", "Draft"), ("published", "Published")], 32 | max_length=9, 33 | ), 34 | ), 35 | ( 36 | "media_type", 37 | models.ForeignKey( 38 | on_delete=django.db.models.deletion.PROTECT, 39 | to="contenttypes.contenttype", 40 | ), 41 | ), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name="Image", 46 | fields=[ 47 | ( 48 | "mediaasset_ptr", 49 | models.OneToOneField( 50 | auto_created=True, 51 | on_delete=django.db.models.deletion.CASCADE, 52 | parent_link=True, 53 | primary_key=True, 54 | serialize=False, 55 | to="djangopress_media.mediaasset", 56 | ), 57 | ), 58 | ], 59 | bases=("djangopress_media.mediaasset",), 60 | ), 61 | ] 62 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0002_mediaasset_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-05-26 10:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("djangopress_media", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="mediaasset", 14 | name="file", 15 | field=models.FileField(default="", upload_to=""), 16 | preserve_default=False, 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0003_thumbnail_alter_mediaasset_file_mediaasset_thumbnail.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-05-26 11:51 2 | 3 | import django.db.models.deletion 4 | import djangopress.media.models 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("djangopress_media", "0002_mediaasset_file"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="Thumbnail", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("file", models.FileField(upload_to="thumbnails")), 27 | ], 28 | ), 29 | migrations.AlterField( 30 | model_name="mediaasset", 31 | name="file", 32 | field=models.FileField(upload_to=djangopress.media.models.get_upload_to), 33 | ), 34 | migrations.AddField( 35 | model_name="mediaasset", 36 | name="thumbnail", 37 | field=models.ForeignKey( 38 | null=True, 39 | on_delete=django.db.models.deletion.SET_NULL, 40 | to="djangopress_media.thumbnail", 41 | ), 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0004_mediaasset_owner.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-16 14:34 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ( 11 | "djangopress_media", 12 | "0003_thumbnail_alter_mediaasset_file_mediaasset_thumbnail", 13 | ), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.AddField( 19 | model_name="mediaasset", 20 | name="owner", 21 | field=models.ForeignKey( 22 | default=1, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | to=settings.AUTH_USER_MODEL, 25 | ), 26 | preserve_default=False, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0005_mediaasset_file_content_type_mediaasset_file_hash_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-25 22:21 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("djangopress_media", "0004_mediaasset_owner"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="mediaasset", 14 | name="file_content_type", 15 | field=models.CharField(default="", max_length=100), 16 | preserve_default=False, 17 | ), 18 | migrations.AddField( 19 | model_name="mediaasset", 20 | name="file_hash", 21 | field=models.CharField(default="", max_length=40), 22 | preserve_default=False, 23 | ), 24 | migrations.AddField( 25 | model_name="mediaasset", 26 | name="file_size", 27 | field=models.PositiveIntegerField(default=0), 28 | preserve_default=False, 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/0006_remove_mediaasset_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.7 on 2025-03-28 21:26 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "djangopress_media", 10 | "0005_mediaasset_file_content_type_mediaasset_file_hash_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name="mediaasset", 17 | name="status", 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /server/djangopress/media/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/media/migrations/__init__.py -------------------------------------------------------------------------------- /server/djangopress/media/models.py: -------------------------------------------------------------------------------- 1 | import filetype 2 | 3 | from django.contrib.contenttypes.models import ContentType 4 | from django.core.files.uploadedfile import UploadedFile 5 | from django.conf import settings 6 | from django.db import models 7 | 8 | from djangopress.auth.models import User 9 | from .utils import generate_thumbnail, hash_filelike 10 | 11 | 12 | class Thumbnail(models.Model): 13 | file = models.FileField(upload_to="thumbnails") 14 | 15 | 16 | def get_upload_to(instance, filename): 17 | return instance.upload_to + "/" + filename 18 | 19 | 20 | class MediaAsset(models.Model): 21 | owner = models.ForeignKey(User, on_delete=models.CASCADE) 22 | title = models.TextField() 23 | media_type = models.ForeignKey(ContentType, on_delete=models.PROTECT) 24 | thumbnail = models.ForeignKey(Thumbnail, on_delete=models.SET_NULL, null=True) 25 | file = models.FileField(upload_to=get_upload_to) 26 | file_size = models.PositiveIntegerField() 27 | file_hash = models.CharField(max_length=40) 28 | file_content_type = models.CharField(max_length=100) 29 | 30 | ALLOWED_FILE_TYPES = [] 31 | 32 | class InvalidFileError(ValueError): 33 | pass 34 | 35 | def _set_file_metadata(self, file): 36 | self.file_size = file.size 37 | self.file_hash = hash_filelike(file) 38 | self.file_content_type = filetype.guess_mime(file) 39 | 40 | # validate file size, reject files larger than MAX_UPLOAD_SIZE 41 | if self.file_size > settings.MAX_UPLOAD_SIZE: 42 | raise self.InvalidFileError("File size is too large.") 43 | 44 | if self.file_content_type is None: 45 | raise self.InvalidFileError("The file type could not be determined.") 46 | 47 | if self.file_content_type not in self.ALLOWED_FILE_TYPES: 48 | raise self.InvalidFileError( 49 | f"File type '{self.file_content_type}' is not supported." 50 | ) 51 | 52 | 53 | class Image(MediaAsset): 54 | upload_to = "images" 55 | 56 | ALLOWED_FILE_TYPES = [ 57 | "image/jpeg", 58 | "image/png", 59 | ] 60 | 61 | def generate_thumbnail(self): 62 | file = generate_thumbnail(self.file, 300, 300) 63 | 64 | self.thumbnail = Thumbnail.objects.create( 65 | file=UploadedFile( 66 | file=file, 67 | name=self.file.name, 68 | content_type="image/jpeg", 69 | size=file.getbuffer().nbytes 70 | ) 71 | ) 72 | -------------------------------------------------------------------------------- /server/djangopress/media/utils.py: -------------------------------------------------------------------------------- 1 | import math 2 | from hashlib import sha1 3 | from io import BytesIO, UnsupportedOperation 4 | from PIL import Image 5 | 6 | from django.utils.encoding import force_bytes 7 | 8 | 9 | def generate_thumbnail(file, target_width, target_height): 10 | with Image.open(file) as image: 11 | source_width, source_height = image.size 12 | 13 | # Crop the image to fit the final aspect ratio 14 | target_aspect_ratio = target_width / target_height 15 | crop_width = min(source_width, source_height * target_aspect_ratio) 16 | crop_height = crop_width / target_aspect_ratio 17 | crop_left = int(math.floor(source_width / 2 - crop_width / 2)) 18 | crop_top = int(math.floor(source_height / 2 - crop_height / 2)) 19 | crop_right = int(math.ceil(source_width / 2 + crop_width / 2)) 20 | crop_bottom = int(math.ceil(source_height / 2 + crop_height / 2)) 21 | image = image.crop((crop_left, crop_top, crop_right, crop_bottom)) 22 | 23 | # Resize the image (if it is big enough) 24 | scale = target_width / crop_width 25 | if scale < 1.0: 26 | image = image.resize((target_width, target_height)) 27 | 28 | # Replace transparent background with white 29 | if image.mode in ("RGBA", "LA") or ( 30 | image.mode == "P" and "transparency" in image.info 31 | ): 32 | new_image = new_image = Image.new( 33 | "RGBA", image.size, (255, 255, 255, 255) 34 | ) 35 | new_image.alpha_composite(image.convert("RGBA")) 36 | image = new_image 37 | 38 | image = image.convert("RGB") 39 | out = BytesIO() 40 | image.save(out, "JPEG", quality=85, progressive=True) 41 | return out 42 | 43 | 44 | HASH_READ_SIZE = 65536 # 64k 45 | 46 | 47 | # Copied from https://github.com/wagtail/wagtail/blob/main/wagtail/utils/file.py 48 | def hash_filelike(filelike): 49 | """ 50 | Compute the hash of a file-like object, without loading it all into memory. 51 | """ 52 | file_pos = 0 53 | if hasattr(filelike, "tell"): 54 | file_pos = filelike.tell() 55 | 56 | try: 57 | # Reset file handler to the start of the file so we hash it all 58 | filelike.seek(0) 59 | except (AttributeError, UnsupportedOperation): 60 | pass 61 | 62 | hasher = sha1() 63 | while True: 64 | data = filelike.read(HASH_READ_SIZE) 65 | if not data: 66 | break 67 | # Use `force_bytes` to account for files opened as text 68 | hasher.update(force_bytes(data)) 69 | 70 | if hasattr(filelike, "seek"): 71 | # Reset the file handler to where it was before 72 | filelike.seek(file_pos) 73 | 74 | return hasher.hexdigest() 75 | -------------------------------------------------------------------------------- /server/djangopress/media/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.contenttypes.models import ContentType 2 | from django.contrib import messages 3 | from django_bridge.response import Response, CloseOverlayResponse 4 | from django.urls import reverse 5 | from django.shortcuts import get_object_or_404 6 | 7 | from .forms import UploadForm, EditForm 8 | from .models import MediaAsset, Image 9 | 10 | 11 | def index(request): 12 | assets = MediaAsset.objects.filter(owner=request.user) 13 | 14 | return Response( 15 | request, 16 | "MediaIndex", 17 | { 18 | "assets": [ 19 | { 20 | "id": asset.id, 21 | "title": asset.title, 22 | "edit_url": reverse("media_edit", args=[asset.id]), 23 | "thumbnail_url": asset.thumbnail.file.url 24 | if asset.thumbnail 25 | else None, 26 | } 27 | for asset in assets 28 | ], 29 | "upload_url": reverse("media_upload"), 30 | }, 31 | title="Media | Djangopress", 32 | ) 33 | 34 | 35 | def upload(request): 36 | form = UploadForm(request.POST or None, request.FILES or None) 37 | 38 | if form.is_valid(): 39 | try: 40 | image = form.save(commit=False) 41 | image._set_file_metadata(request.FILES["file"]) 42 | image.owner = request.user 43 | image.media_type = ContentType.objects.get_for_model(Image) 44 | image.generate_thumbnail() 45 | image.save() 46 | 47 | messages.success( 48 | request, 49 | f"Successfully added image '{image.title}'.", 50 | ) 51 | 52 | return CloseOverlayResponse(request) 53 | 54 | except Image.InvalidFileError as e: 55 | form.add_error("file", e.args[0]) 56 | 57 | return HttpResponse(400) 58 | 59 | 60 | def edit(request, mediaasset_id): 61 | image = get_object_or_404(Image, owner=request.user, id=mediaasset_id) 62 | form = EditForm(request.POST or None, instance=image) 63 | 64 | if form.is_valid(): 65 | form.save() 66 | 67 | messages.success( 68 | request, 69 | f"Successfully saved image '{image.title}'.", 70 | ) 71 | 72 | return Response( 73 | request, 74 | "MediaForm", 75 | { 76 | "title": "Edit Image", 77 | "submit_button_label": "Save", 78 | "action_url": reverse("media_edit", args=[mediaasset_id]), 79 | "form": form, 80 | }, 81 | overlay=True, 82 | title=f"Editing {image.title} | Djangopress", 83 | ) 84 | 85 | 86 | def delete(request, mediaasset_id): 87 | asset = get_object_or_404(MediaAsset, owner=request.user, id=mediaasset_id) 88 | 89 | if request.method == "POST": 90 | asset.delete() 91 | 92 | messages.success( 93 | request, 94 | f"Successfully deleted asset '{post.title}'.", 95 | ) 96 | 97 | return CloseOverlayResponse(request) 98 | 99 | return Response( 100 | request, 101 | "ConfirmDelete", 102 | { 103 | "objectName": asset.title, 104 | "messageHtml": "Are you sure that you want to delete this asset?", 105 | "actionUrl": reverse("posts_delete", args=[post.id]), 106 | }, 107 | overlay=True, 108 | ) 109 | -------------------------------------------------------------------------------- /server/djangopress/posts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/posts/__init__.py -------------------------------------------------------------------------------- /server/djangopress/posts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PostsConfig(AppConfig): 5 | name = "djangopress.posts" 6 | label = "djangopress_posts" 7 | -------------------------------------------------------------------------------- /server/djangopress/posts/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | from .models import Post 4 | from ..widgets import BlockNoteEditor 5 | 6 | 7 | class PostForm(forms.ModelForm): 8 | class Meta: 9 | model = Post 10 | fields = ["title", "content"] 11 | widgets = { 12 | "title": forms.TextInput(attrs={"variant": "large"}), 13 | "content": BlockNoteEditor(), 14 | } 15 | -------------------------------------------------------------------------------- /server/djangopress/posts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.1 on 2024-01-10 22:53 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Post", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("title", models.TextField()), 25 | ( 26 | "status", 27 | models.CharField( 28 | choices=[("draft", "Draft"), ("published", "Published")], 29 | max_length=9, 30 | ), 31 | ), 32 | ("content", models.TextField()), 33 | ], 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /server/djangopress/posts/migrations/0002_post_content.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-05-27 12:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("djangopress_posts", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="post", 14 | name="content", 15 | ), 16 | migrations.AddField( 17 | model_name="post", 18 | name="content", 19 | field=models.JSONField(default=[]), 20 | preserve_default=False, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /server/djangopress/posts/migrations/0003_post_owner.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-16 14:34 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("djangopress_posts", "0002_post_content"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="post", 17 | name="owner", 18 | field=models.ForeignKey( 19 | default=1, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | to=settings.AUTH_USER_MODEL, 22 | ), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /server/djangopress/posts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/django-bridge/django-react-cms/2a5063665b78b9793e52c2b5a0379becb4191143/server/djangopress/posts/migrations/__init__.py -------------------------------------------------------------------------------- /server/djangopress/posts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from djangopress.auth.models import User 4 | 5 | 6 | class Post(models.Model): 7 | class Status(models.TextChoices): 8 | DRAFT = "draft", "Draft" 9 | PUBLISHED = "published", "Published" 10 | 11 | owner = models.ForeignKey(User, on_delete=models.CASCADE) 12 | title = models.TextField() 13 | status = models.CharField(max_length=9, choices=Status.choices) 14 | content = models.JSONField() 15 | -------------------------------------------------------------------------------- /server/djangopress/posts/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib import messages 2 | from django.urls import reverse 3 | from django.shortcuts import get_object_or_404 4 | from django_bridge.response import CloseOverlayResponse, Response 5 | 6 | from .forms import PostForm 7 | from .models import Post 8 | 9 | 10 | def index(request): 11 | posts = Post.objects.filter(owner=request.user) 12 | 13 | return Response( 14 | request, 15 | "PostIndex", 16 | { 17 | "posts": [ 18 | { 19 | "title": post.title, 20 | "edit_url": reverse("posts_edit", args=[post.id]), 21 | "delete_url": reverse("posts_delete", args=[post.id]), 22 | } 23 | for post in posts 24 | ] 25 | }, 26 | title="Posts | Djangopress", 27 | ) 28 | 29 | 30 | def add(request): 31 | form = PostForm(request.POST or None) 32 | 33 | if form.is_valid(): 34 | post = form.save(commit=False) 35 | post.owner = request.user 36 | post.save() 37 | 38 | messages.success( 39 | request, 40 | f"Successfully added post '{post.title}'.", 41 | ) 42 | 43 | return CloseOverlayResponse(request) 44 | 45 | return Response( 46 | request, 47 | "PostForm", 48 | { 49 | "action_url": reverse("posts_add"), 50 | "form": form, 51 | }, 52 | overlay=True, 53 | title="Add Post | Djangopress", 54 | ) 55 | 56 | 57 | def edit(request, post_id): 58 | post = get_object_or_404(Post, owner=request.user, id=post_id) 59 | form = PostForm(request.POST or None, instance=post) 60 | 61 | if form.is_valid(): 62 | form.save() 63 | 64 | messages.success( 65 | request, 66 | f"Successfully saved post '{post.title}'.", 67 | ) 68 | 69 | return Response( 70 | request, 71 | "PostForm", 72 | { 73 | "post": { 74 | "title": post.title, 75 | "edit_url": reverse("posts_edit", args=[post.id]), 76 | "delete_url": reverse("posts_delete", args=[post.id]), 77 | }, 78 | "action_url": reverse("posts_edit", args=[post_id]), 79 | "form": form, 80 | }, 81 | ) 82 | 83 | 84 | def delete(request, post_id): 85 | post = get_object_or_404(Post, owner=request.user, id=post_id) 86 | 87 | if request.method == "POST": 88 | post.delete() 89 | 90 | messages.success( 91 | request, 92 | f"Successfully deleted post '{post.title}'.", 93 | ) 94 | 95 | return CloseOverlayResponse(request) 96 | 97 | return Response( 98 | request, 99 | "ConfirmDelete", 100 | { 101 | "objectName": post.title, 102 | "messageHtml": "Are you sure that you want to delete this post?", 103 | "actionUrl": reverse("posts_delete", args=[post.id]), 104 | }, 105 | overlay=True, 106 | ) 107 | -------------------------------------------------------------------------------- /server/djangopress/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for djangopress project. 3 | 4 | Generated by 'npm create django-bridge' using Django 5.0. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | 13 | import os 14 | from pathlib import Path 15 | from urllib.parse import urlparse 16 | 17 | import dj_database_url 18 | import sentry_sdk 19 | 20 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 21 | BASE_DIR = Path(__file__).resolve().parent.parent 22 | 23 | # Basic Django settings 24 | 25 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 26 | DEBUG = os.environ.get("DJANGO_DEBUG", "false") == "true" 27 | DEMO_MODE = os.environ.get("DEMO_MODE", "false") == "true" 28 | BASE_URL = os.environ["BASE_URL"] 29 | ALLOWED_HOSTS = os.environ.get( 30 | "DJANGO_ALLOWED_HOSTS", urlparse(BASE_URL).hostname 31 | ).split(",") 32 | CSRF_TRUSTED_ORIGINS_STR = os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS") or BASE_URL 33 | if CSRF_TRUSTED_ORIGINS_STR: 34 | CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_STR.split(",") 35 | else: 36 | CSRF_TRUSTED_ORIGINS = [] 37 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 38 | 39 | 40 | # Application definition 41 | 42 | INSTALLED_APPS = [ 43 | "djangopress.posts", 44 | "djangopress.media", 45 | "djangopress.auth", 46 | "djangopress", 47 | "django_bridge", 48 | "django.contrib.admin", 49 | "django.contrib.auth", 50 | "django.contrib.contenttypes", 51 | "django.contrib.sessions", 52 | "django.contrib.messages", 53 | "django.contrib.staticfiles", 54 | ] 55 | 56 | MIDDLEWARE = [ 57 | "django.middleware.security.SecurityMiddleware", 58 | "whitenoise.middleware.WhiteNoiseMiddleware", 59 | "django.contrib.sessions.middleware.SessionMiddleware", 60 | "django.middleware.common.CommonMiddleware", 61 | "django.middleware.csrf.CsrfViewMiddleware", 62 | "django.contrib.auth.middleware.AuthenticationMiddleware", 63 | "django.contrib.messages.middleware.MessageMiddleware", 64 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 65 | "django_bridge.middleware.DjangoBridgeMiddleware", 66 | ] 67 | 68 | ROOT_URLCONF = "djangopress.urls" 69 | 70 | TEMPLATES = [ 71 | { 72 | "BACKEND": "django.template.backends.django.DjangoTemplates", 73 | "DIRS": [], 74 | "APP_DIRS": True, 75 | "OPTIONS": { 76 | "context_processors": [ 77 | "django.template.context_processors.debug", 78 | "django.template.context_processors.request", 79 | "django.contrib.auth.context_processors.auth", 80 | "django.contrib.messages.context_processors.messages", 81 | ], 82 | }, 83 | }, 84 | ] 85 | 86 | WSGI_APPLICATION = "djangopress.wsgi.application" 87 | 88 | 89 | # Database 90 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 91 | 92 | DATABASES = {"default": dj_database_url.config(default="sqlite:///db.sqlite3")} 93 | 94 | 95 | # Password validation 96 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 97 | 98 | AUTH_PASSWORD_VALIDATORS = [ 99 | { 100 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 101 | }, 102 | { 103 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 104 | }, 105 | { 106 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 107 | }, 108 | { 109 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 110 | }, 111 | ] 112 | 113 | 114 | # Internationalization 115 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 116 | 117 | LANGUAGE_CODE = "en-us" 118 | 119 | TIME_ZONE = "UTC" 120 | 121 | USE_I18N = True 122 | 123 | USE_TZ = True 124 | 125 | 126 | # Static files (CSS, JavaScript, Images) 127 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 128 | 129 | STATIC_ROOT = BASE_DIR / "staticfiles" 130 | STATIC_URL = os.environ.get("DJANGO_STATIC_URL", "static/") 131 | 132 | if os.environ.get("VITE_BUNDLE_DIR"): 133 | STATICFILES_DIRS = [os.environ["VITE_BUNDLE_DIR"]] 134 | 135 | STORAGES = { 136 | "default": { 137 | "BACKEND": "django.core.files.storage.FileSystemStorage", 138 | }, 139 | "staticfiles": { 140 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 141 | }, 142 | } 143 | 144 | MEDIA_URL = "media/" 145 | MEDIA_ROOT = BASE_DIR / "media" 146 | MAX_UPLOAD_SIZE = 2 * 1024 * 1024 147 | 148 | 149 | # Default primary key field type 150 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 151 | 152 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 153 | 154 | # Auth 155 | 156 | AUTH_USER_MODEL = "djangopress_auth.User" 157 | LOGIN_URL = "login" 158 | LOGIN_REDIRECT_URL = "/" 159 | 160 | # Logging 161 | # Log all warnings to console 162 | 163 | LOGGING = { 164 | "version": 1, 165 | "disable_existing_loggers": False, 166 | "handlers": { 167 | "console": { 168 | "class": "logging.StreamHandler", 169 | }, 170 | }, 171 | "root": { 172 | "handlers": ["console"], 173 | "level": "WARNING", 174 | }, 175 | } 176 | 177 | # Sentry 178 | 179 | if "SENTRY_DSN" in os.environ: 180 | sentry_sdk.init( 181 | dsn=os.environ["SENTRY_DSN"], 182 | # Set traces_sample_rate to 1.0 to capture 100% 183 | # of transactions for performance monitoring. 184 | traces_sample_rate=1.0, 185 | # Set profiles_sample_rate to 1.0 to profile 100% 186 | # of sampled transactions. 187 | # We recommend adjusting this value in production. 188 | profiles_sample_rate=1.0, 189 | ) 190 | 191 | # Django Bridge settings 192 | 193 | DJANGO_BRIDGE = { 194 | "VITE_BUNDLE_DIR": os.environ.get("VITE_BUNDLE_DIR"), 195 | "VITE_DEVSERVER_URL": os.environ.get("VITE_SERVER_ORIGIN"), 196 | "CONTEXT_PROVIDERS": { 197 | "csrf_token": "django.middleware.csrf.get_token", 198 | "urls": "djangopress.context_providers.urls", 199 | }, 200 | } 201 | -------------------------------------------------------------------------------- /server/djangopress/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.decorators import login_required 3 | from django.urls import path 4 | from django.conf.urls.static import static 5 | from django.conf import settings 6 | 7 | from . import views 8 | from .auth import views as auth_views 9 | from .media import views as media_views 10 | from .posts import views as posts_views 11 | from .utils import decorate_urlpatterns 12 | 13 | # Put any URLs that require authentication in this list. 14 | urlpatterns_auth = [ 15 | path("admin/", admin.site.urls), 16 | path("", views.home, name="home"), 17 | # Posts 18 | path("posts/", posts_views.index, name="posts_index"), 19 | path("posts/add/", posts_views.add, name="posts_add"), 20 | path("posts//edit/", posts_views.edit, name="posts_edit"), 21 | path("posts//delete/", posts_views.delete, name="posts_delete"), 22 | # Media 23 | path("media/", media_views.index, name="media_index"), 24 | path("media/upload/", media_views.upload, name="media_upload"), 25 | path("media//edit/", media_views.edit, name="media_edit"), 26 | path("media//delete/", media_views.delete, name="media_delete"), 27 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 28 | 29 | # Put any URLs that do not require authentication in this list. 30 | urlpatterns_noauth = [ 31 | path("login/", auth_views.LoginView.as_view(), name="login"), 32 | ] 33 | 34 | if settings.DEMO_MODE: 35 | urlpatterns_noauth += [ 36 | path("login-temporary/", auth_views.login_temporary, name="login_temporary"), 37 | ] 38 | 39 | urlpatterns = urlpatterns_noauth + decorate_urlpatterns( 40 | urlpatterns_auth, login_required 41 | ) 42 | -------------------------------------------------------------------------------- /server/djangopress/utils.py: -------------------------------------------------------------------------------- 1 | from functools import update_wrapper 2 | 3 | 4 | def decorate_urlpatterns(urlpatterns, decorator): 5 | """ 6 | Decorate all the views in the passed urlpatterns list with the given decorator 7 | """ 8 | for pattern in urlpatterns: 9 | if hasattr(pattern, "url_patterns"): 10 | # this is an included RegexURLResolver; recursively decorate the views 11 | # contained in it 12 | decorate_urlpatterns(pattern.url_patterns, decorator) 13 | 14 | if getattr(pattern, "callback", None): 15 | pattern.callback = update_wrapper( 16 | decorator(pattern.callback), pattern.callback 17 | ) 18 | 19 | return urlpatterns 20 | -------------------------------------------------------------------------------- /server/djangopress/views.py: -------------------------------------------------------------------------------- 1 | from django_bridge.response import Response 2 | 3 | 4 | def home(request): 5 | return Response(request, "Home", {}) 6 | -------------------------------------------------------------------------------- /server/djangopress/widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms import TextInput 2 | 3 | 4 | class BlockNoteEditor(TextInput): 5 | pass 6 | -------------------------------------------------------------------------------- /server/djangopress/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for djangopress project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangopress.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /server/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangopress.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /server/media/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /server/poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "asgiref" 5 | version = "3.8.1" 6 | description = "ASGI specs, helper code, and adapters" 7 | optional = false 8 | python-versions = ">=3.8" 9 | files = [ 10 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 11 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 12 | ] 13 | 14 | [package.extras] 15 | tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] 16 | 17 | [[package]] 18 | name = "black" 19 | version = "23.12.1" 20 | description = "The uncompromising code formatter." 21 | optional = false 22 | python-versions = ">=3.8" 23 | files = [ 24 | {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, 25 | {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, 26 | {file = "black-23.12.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0"}, 27 | {file = "black-23.12.1-cp310-cp310-win_amd64.whl", hash = "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3"}, 28 | {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, 29 | {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, 30 | {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, 31 | {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, 32 | {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, 33 | {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, 34 | {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, 35 | {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, 36 | {file = "black-23.12.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f"}, 37 | {file = "black-23.12.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d"}, 38 | {file = "black-23.12.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a"}, 39 | {file = "black-23.12.1-cp38-cp38-win_amd64.whl", hash = "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e"}, 40 | {file = "black-23.12.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055"}, 41 | {file = "black-23.12.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54"}, 42 | {file = "black-23.12.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea"}, 43 | {file = "black-23.12.1-cp39-cp39-win_amd64.whl", hash = "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2"}, 44 | {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, 45 | {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, 46 | ] 47 | 48 | [package.dependencies] 49 | click = ">=8.0.0" 50 | mypy-extensions = ">=0.4.3" 51 | packaging = ">=22.0" 52 | pathspec = ">=0.9.0" 53 | platformdirs = ">=2" 54 | 55 | [package.extras] 56 | colorama = ["colorama (>=0.4.3)"] 57 | d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] 58 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 59 | uvloop = ["uvloop (>=0.15.2)"] 60 | 61 | [[package]] 62 | name = "certifi" 63 | version = "2024.7.4" 64 | description = "Python package for providing Mozilla's CA Bundle." 65 | optional = false 66 | python-versions = ">=3.6" 67 | files = [ 68 | {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, 69 | {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, 70 | ] 71 | 72 | [[package]] 73 | name = "click" 74 | version = "8.1.7" 75 | description = "Composable command line interface toolkit" 76 | optional = false 77 | python-versions = ">=3.7" 78 | files = [ 79 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 80 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 81 | ] 82 | 83 | [package.dependencies] 84 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 85 | 86 | [[package]] 87 | name = "colorama" 88 | version = "0.4.6" 89 | description = "Cross-platform colored terminal text." 90 | optional = false 91 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 92 | files = [ 93 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 94 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 95 | ] 96 | 97 | [[package]] 98 | name = "dj-database-url" 99 | version = "2.2.0" 100 | description = "Use Database URLs in your Django Application." 101 | optional = false 102 | python-versions = "*" 103 | files = [ 104 | {file = "dj_database_url-2.2.0-py3-none-any.whl", hash = "sha256:3e792567b0aa9a4884860af05fe2aa4968071ad351e033b6db632f97ac6db9de"}, 105 | {file = "dj_database_url-2.2.0.tar.gz", hash = "sha256:9f9b05058ddf888f1e6f840048b8d705ff9395e3b52a07165daa3d8b9360551b"}, 106 | ] 107 | 108 | [package.dependencies] 109 | Django = ">=3.2" 110 | typing_extensions = ">=3.10.0.0" 111 | 112 | [[package]] 113 | name = "django" 114 | version = "5.0.7" 115 | description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 116 | optional = false 117 | python-versions = ">=3.10" 118 | files = [ 119 | {file = "Django-5.0.7-py3-none-any.whl", hash = "sha256:f216510ace3de5de01329463a315a629f33480e893a9024fc93d8c32c22913da"}, 120 | {file = "Django-5.0.7.tar.gz", hash = "sha256:bd4505cae0b9bd642313e8fb71810893df5dc2ffcacaa67a33af2d5cd61888f2"}, 121 | ] 122 | 123 | [package.dependencies] 124 | asgiref = ">=3.7.0,<4" 125 | sqlparse = ">=0.3.1" 126 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 127 | 128 | [package.extras] 129 | argon2 = ["argon2-cffi (>=19.1.0)"] 130 | bcrypt = ["bcrypt"] 131 | 132 | [[package]] 133 | name = "django-bridge" 134 | version = "0.3" 135 | description = "The simple way to build Django applications with modern React frontends" 136 | optional = false 137 | python-versions = "*" 138 | files = [ 139 | {file = "django-bridge-0.3.tar.gz", hash = "sha256:79ccd123c0d904f037857812ee9abcadd4d5ff10dcfd992134d48cbfb5b6075f"}, 140 | {file = "django_bridge-0.3-py3-none-any.whl", hash = "sha256:c39f33e0d53746145152e91b06577635db5ea81eedb9204d205d747cff286513"}, 141 | ] 142 | 143 | [package.dependencies] 144 | Django = ">=4.0,<6.0" 145 | telepath = ">=0.3,<0.4" 146 | 147 | [[package]] 148 | name = "filetype" 149 | version = "1.2.0" 150 | description = "Infer file type and MIME type of any file/buffer. No external dependencies." 151 | optional = false 152 | python-versions = "*" 153 | files = [ 154 | {file = "filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25"}, 155 | {file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"}, 156 | ] 157 | 158 | [[package]] 159 | name = "gunicorn" 160 | version = "21.2.0" 161 | description = "WSGI HTTP Server for UNIX" 162 | optional = false 163 | python-versions = ">=3.5" 164 | files = [ 165 | {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, 166 | {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, 167 | ] 168 | 169 | [package.dependencies] 170 | packaging = "*" 171 | 172 | [package.extras] 173 | eventlet = ["eventlet (>=0.24.1)"] 174 | gevent = ["gevent (>=1.4.0)"] 175 | setproctitle = ["setproctitle"] 176 | tornado = ["tornado (>=0.2)"] 177 | 178 | [[package]] 179 | name = "isort" 180 | version = "5.13.2" 181 | description = "A Python utility / library to sort Python imports." 182 | optional = false 183 | python-versions = ">=3.8.0" 184 | files = [ 185 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 186 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 187 | ] 188 | 189 | [package.extras] 190 | colors = ["colorama (>=0.4.6)"] 191 | 192 | [[package]] 193 | name = "mypy-extensions" 194 | version = "1.0.0" 195 | description = "Type system extensions for programs checked with the mypy type checker." 196 | optional = false 197 | python-versions = ">=3.5" 198 | files = [ 199 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 200 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 201 | ] 202 | 203 | [[package]] 204 | name = "packaging" 205 | version = "24.1" 206 | description = "Core utilities for Python packages" 207 | optional = false 208 | python-versions = ">=3.8" 209 | files = [ 210 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 211 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 212 | ] 213 | 214 | [[package]] 215 | name = "pathspec" 216 | version = "0.12.1" 217 | description = "Utility library for gitignore style pattern matching of file paths." 218 | optional = false 219 | python-versions = ">=3.8" 220 | files = [ 221 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 222 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 223 | ] 224 | 225 | [[package]] 226 | name = "pillow" 227 | version = "10.4.0" 228 | description = "Python Imaging Library (Fork)" 229 | optional = false 230 | python-versions = ">=3.8" 231 | files = [ 232 | {file = "pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e"}, 233 | {file = "pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d"}, 234 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856"}, 235 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f"}, 236 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b"}, 237 | {file = "pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc"}, 238 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e"}, 239 | {file = "pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46"}, 240 | {file = "pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984"}, 241 | {file = "pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141"}, 242 | {file = "pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1"}, 243 | {file = "pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c"}, 244 | {file = "pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be"}, 245 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3"}, 246 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6"}, 247 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe"}, 248 | {file = "pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319"}, 249 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d"}, 250 | {file = "pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696"}, 251 | {file = "pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496"}, 252 | {file = "pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91"}, 253 | {file = "pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22"}, 254 | {file = "pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94"}, 255 | {file = "pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597"}, 256 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80"}, 257 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca"}, 258 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef"}, 259 | {file = "pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a"}, 260 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b"}, 261 | {file = "pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9"}, 262 | {file = "pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42"}, 263 | {file = "pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a"}, 264 | {file = "pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9"}, 265 | {file = "pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3"}, 266 | {file = "pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb"}, 267 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70"}, 268 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be"}, 269 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0"}, 270 | {file = "pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc"}, 271 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a"}, 272 | {file = "pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309"}, 273 | {file = "pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060"}, 274 | {file = "pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea"}, 275 | {file = "pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d"}, 276 | {file = "pillow-10.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8d4d5063501b6dd4024b8ac2f04962d661222d120381272deea52e3fc52d3736"}, 277 | {file = "pillow-10.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7c1ee6f42250df403c5f103cbd2768a28fe1a0ea1f0f03fe151c8741e1469c8b"}, 278 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15e02e9bb4c21e39876698abf233c8c579127986f8207200bc8a8f6bb27acf2"}, 279 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a8d4bade9952ea9a77d0c3e49cbd8b2890a399422258a77f357b9cc9be8d680"}, 280 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:43efea75eb06b95d1631cb784aa40156177bf9dd5b4b03ff38979e048258bc6b"}, 281 | {file = "pillow-10.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:950be4d8ba92aca4b2bb0741285a46bfae3ca699ef913ec8416c1b78eadd64cd"}, 282 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d7480af14364494365e89d6fddc510a13e5a2c3584cb19ef65415ca57252fb84"}, 283 | {file = "pillow-10.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:73664fe514b34c8f02452ffb73b7a92c6774e39a647087f83d67f010eb9a0cf0"}, 284 | {file = "pillow-10.4.0-cp38-cp38-win32.whl", hash = "sha256:e88d5e6ad0d026fba7bdab8c3f225a69f063f116462c49892b0149e21b6c0a0e"}, 285 | {file = "pillow-10.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:5161eef006d335e46895297f642341111945e2c1c899eb406882a6c61a4357ab"}, 286 | {file = "pillow-10.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0ae24a547e8b711ccaaf99c9ae3cd975470e1a30caa80a6aaee9a2f19c05701d"}, 287 | {file = "pillow-10.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:298478fe4f77a4408895605f3482b6cc6222c018b2ce565c2b6b9c354ac3229b"}, 288 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:134ace6dc392116566980ee7436477d844520a26a4b1bd4053f6f47d096997fd"}, 289 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:930044bb7679ab003b14023138b50181899da3f25de50e9dbee23b61b4de2126"}, 290 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:c76e5786951e72ed3686e122d14c5d7012f16c8303a674d18cdcd6d89557fc5b"}, 291 | {file = "pillow-10.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:b2724fdb354a868ddf9a880cb84d102da914e99119211ef7ecbdc613b8c96b3c"}, 292 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dbc6ae66518ab3c5847659e9988c3b60dc94ffb48ef9168656e0019a93dbf8a1"}, 293 | {file = "pillow-10.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:06b2f7898047ae93fad74467ec3d28fe84f7831370e3c258afa533f81ef7f3df"}, 294 | {file = "pillow-10.4.0-cp39-cp39-win32.whl", hash = "sha256:7970285ab628a3779aecc35823296a7869f889b8329c16ad5a71e4901a3dc4ef"}, 295 | {file = "pillow-10.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:961a7293b2457b405967af9c77dcaa43cc1a8cd50d23c532e62d48ab6cdd56f5"}, 296 | {file = "pillow-10.4.0-cp39-cp39-win_arm64.whl", hash = "sha256:32cda9e3d601a52baccb2856b8ea1fc213c90b340c542dcef77140dfa3278a9e"}, 297 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4"}, 298 | {file = "pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da"}, 299 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026"}, 300 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e"}, 301 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5"}, 302 | {file = "pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885"}, 303 | {file = "pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5"}, 304 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a02364621fe369e06200d4a16558e056fe2805d3468350df3aef21e00d26214b"}, 305 | {file = "pillow-10.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:1b5dea9831a90e9d0721ec417a80d4cbd7022093ac38a568db2dd78363b00908"}, 306 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b885f89040bb8c4a1573566bbb2f44f5c505ef6e74cec7ab9068c900047f04b"}, 307 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87dd88ded2e6d74d31e1e0a99a726a6765cda32d00ba72dc37f0651f306daaa8"}, 308 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:2db98790afc70118bd0255c2eeb465e9767ecf1f3c25f9a1abb8ffc8cfd1fe0a"}, 309 | {file = "pillow-10.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f7baece4ce06bade126fb84b8af1c33439a76d8a6fd818970215e0560ca28c27"}, 310 | {file = "pillow-10.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:cfdd747216947628af7b259d274771d84db2268ca062dd5faf373639d00113a3"}, 311 | {file = "pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06"}, 312 | ] 313 | 314 | [package.extras] 315 | docs = ["furo", "olefile", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] 316 | fpx = ["olefile"] 317 | mic = ["olefile"] 318 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 319 | typing = ["typing-extensions"] 320 | xmp = ["defusedxml"] 321 | 322 | [[package]] 323 | name = "platformdirs" 324 | version = "4.2.2" 325 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 326 | optional = false 327 | python-versions = ">=3.8" 328 | files = [ 329 | {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, 330 | {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, 331 | ] 332 | 333 | [package.extras] 334 | docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] 335 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] 336 | type = ["mypy (>=1.8)"] 337 | 338 | [[package]] 339 | name = "psycopg" 340 | version = "3.2.1" 341 | description = "PostgreSQL database adapter for Python" 342 | optional = false 343 | python-versions = ">=3.8" 344 | files = [ 345 | {file = "psycopg-3.2.1-py3-none-any.whl", hash = "sha256:ece385fb413a37db332f97c49208b36cf030ff02b199d7635ed2fbd378724175"}, 346 | {file = "psycopg-3.2.1.tar.gz", hash = "sha256:dc8da6dc8729dacacda3cc2f17d2c9397a70a66cf0d2b69c91065d60d5f00cb7"}, 347 | ] 348 | 349 | [package.dependencies] 350 | typing-extensions = ">=4.4" 351 | tzdata = {version = "*", markers = "sys_platform == \"win32\""} 352 | 353 | [package.extras] 354 | binary = ["psycopg-binary (==3.2.1)"] 355 | c = ["psycopg-c (==3.2.1)"] 356 | dev = ["ast-comments (>=1.1.2)", "black (>=24.1.0)", "codespell (>=2.2)", "dnspython (>=2.1)", "flake8 (>=4.0)", "mypy (>=1.6)", "types-setuptools (>=57.4)", "wheel (>=0.37)"] 357 | docs = ["Sphinx (>=5.0)", "furo (==2022.6.21)", "sphinx-autobuild (>=2021.3.14)", "sphinx-autodoc-typehints (>=1.12)"] 358 | pool = ["psycopg-pool"] 359 | test = ["anyio (>=4.0)", "mypy (>=1.6)", "pproxy (>=2.7)", "pytest (>=6.2.5)", "pytest-cov (>=3.0)", "pytest-randomly (>=3.5)"] 360 | 361 | [[package]] 362 | name = "sentry-sdk" 363 | version = "2.10.0" 364 | description = "Python client for Sentry (https://sentry.io)" 365 | optional = false 366 | python-versions = ">=3.6" 367 | files = [ 368 | {file = "sentry_sdk-2.10.0-py2.py3-none-any.whl", hash = "sha256:87b3d413c87d8e7f816cc9334bff255a83d8b577db2b22042651c30c19c09190"}, 369 | {file = "sentry_sdk-2.10.0.tar.gz", hash = "sha256:545fcc6e36c335faa6d6cda84669b6e17025f31efbf3b2211ec14efe008b75d1"}, 370 | ] 371 | 372 | [package.dependencies] 373 | certifi = "*" 374 | django = {version = ">=1.8", optional = true, markers = "extra == \"django\""} 375 | urllib3 = ">=1.26.11" 376 | 377 | [package.extras] 378 | aiohttp = ["aiohttp (>=3.5)"] 379 | anthropic = ["anthropic (>=0.16)"] 380 | arq = ["arq (>=0.23)"] 381 | asyncpg = ["asyncpg (>=0.23)"] 382 | beam = ["apache-beam (>=2.12)"] 383 | bottle = ["bottle (>=0.12.13)"] 384 | celery = ["celery (>=3)"] 385 | celery-redbeat = ["celery-redbeat (>=2)"] 386 | chalice = ["chalice (>=1.16.0)"] 387 | clickhouse-driver = ["clickhouse-driver (>=0.2.0)"] 388 | django = ["django (>=1.8)"] 389 | falcon = ["falcon (>=1.4)"] 390 | fastapi = ["fastapi (>=0.79.0)"] 391 | flask = ["blinker (>=1.1)", "flask (>=0.11)", "markupsafe"] 392 | grpcio = ["grpcio (>=1.21.1)", "protobuf (>=3.8.0)"] 393 | httpx = ["httpx (>=0.16.0)"] 394 | huey = ["huey (>=2)"] 395 | huggingface-hub = ["huggingface-hub (>=0.22)"] 396 | langchain = ["langchain (>=0.0.210)"] 397 | loguru = ["loguru (>=0.5)"] 398 | openai = ["openai (>=1.0.0)", "tiktoken (>=0.3.0)"] 399 | opentelemetry = ["opentelemetry-distro (>=0.35b0)"] 400 | opentelemetry-experimental = ["opentelemetry-instrumentation-aio-pika (==0.46b0)", "opentelemetry-instrumentation-aiohttp-client (==0.46b0)", "opentelemetry-instrumentation-aiopg (==0.46b0)", "opentelemetry-instrumentation-asgi (==0.46b0)", "opentelemetry-instrumentation-asyncio (==0.46b0)", "opentelemetry-instrumentation-asyncpg (==0.46b0)", "opentelemetry-instrumentation-aws-lambda (==0.46b0)", "opentelemetry-instrumentation-boto (==0.46b0)", "opentelemetry-instrumentation-boto3sqs (==0.46b0)", "opentelemetry-instrumentation-botocore (==0.46b0)", "opentelemetry-instrumentation-cassandra (==0.46b0)", "opentelemetry-instrumentation-celery (==0.46b0)", "opentelemetry-instrumentation-confluent-kafka (==0.46b0)", "opentelemetry-instrumentation-dbapi (==0.46b0)", "opentelemetry-instrumentation-django (==0.46b0)", "opentelemetry-instrumentation-elasticsearch (==0.46b0)", "opentelemetry-instrumentation-falcon (==0.46b0)", "opentelemetry-instrumentation-fastapi (==0.46b0)", "opentelemetry-instrumentation-flask (==0.46b0)", "opentelemetry-instrumentation-grpc (==0.46b0)", "opentelemetry-instrumentation-httpx (==0.46b0)", "opentelemetry-instrumentation-jinja2 (==0.46b0)", "opentelemetry-instrumentation-kafka-python (==0.46b0)", "opentelemetry-instrumentation-logging (==0.46b0)", "opentelemetry-instrumentation-mysql (==0.46b0)", "opentelemetry-instrumentation-mysqlclient (==0.46b0)", "opentelemetry-instrumentation-pika (==0.46b0)", "opentelemetry-instrumentation-psycopg (==0.46b0)", "opentelemetry-instrumentation-psycopg2 (==0.46b0)", "opentelemetry-instrumentation-pymemcache (==0.46b0)", "opentelemetry-instrumentation-pymongo (==0.46b0)", "opentelemetry-instrumentation-pymysql (==0.46b0)", "opentelemetry-instrumentation-pyramid (==0.46b0)", "opentelemetry-instrumentation-redis (==0.46b0)", "opentelemetry-instrumentation-remoulade (==0.46b0)", "opentelemetry-instrumentation-requests (==0.46b0)", "opentelemetry-instrumentation-sklearn (==0.46b0)", "opentelemetry-instrumentation-sqlalchemy (==0.46b0)", "opentelemetry-instrumentation-sqlite3 (==0.46b0)", "opentelemetry-instrumentation-starlette (==0.46b0)", "opentelemetry-instrumentation-system-metrics (==0.46b0)", "opentelemetry-instrumentation-threading (==0.46b0)", "opentelemetry-instrumentation-tornado (==0.46b0)", "opentelemetry-instrumentation-tortoiseorm (==0.46b0)", "opentelemetry-instrumentation-urllib (==0.46b0)", "opentelemetry-instrumentation-urllib3 (==0.46b0)", "opentelemetry-instrumentation-wsgi (==0.46b0)"] 401 | pure-eval = ["asttokens", "executing", "pure-eval"] 402 | pymongo = ["pymongo (>=3.1)"] 403 | pyspark = ["pyspark (>=2.4.4)"] 404 | quart = ["blinker (>=1.1)", "quart (>=0.16.1)"] 405 | rq = ["rq (>=0.6)"] 406 | sanic = ["sanic (>=0.8)"] 407 | sqlalchemy = ["sqlalchemy (>=1.2)"] 408 | starlette = ["starlette (>=0.19.1)"] 409 | starlite = ["starlite (>=1.48)"] 410 | tornado = ["tornado (>=6)"] 411 | 412 | [[package]] 413 | name = "sqlparse" 414 | version = "0.5.1" 415 | description = "A non-validating SQL parser." 416 | optional = false 417 | python-versions = ">=3.8" 418 | files = [ 419 | {file = "sqlparse-0.5.1-py3-none-any.whl", hash = "sha256:773dcbf9a5ab44a090f3441e2180efe2560220203dc2f8c0b0fa141e18b505e4"}, 420 | {file = "sqlparse-0.5.1.tar.gz", hash = "sha256:bb6b4df465655ef332548e24f08e205afc81b9ab86cb1c45657a7ff173a3a00e"}, 421 | ] 422 | 423 | [package.extras] 424 | dev = ["build", "hatch"] 425 | doc = ["sphinx"] 426 | 427 | [[package]] 428 | name = "telepath" 429 | version = "0.3.1" 430 | description = "A library for exchanging data between Python and JavaScript" 431 | optional = false 432 | python-versions = ">=3.8" 433 | files = [ 434 | {file = "telepath-0.3.1-py38-none-any.whl", hash = "sha256:c280aa8e77ad71ce80e96500a4e4d4a32f35b7e0b52e896bb5fde9a5bcf0699a"}, 435 | {file = "telepath-0.3.1.tar.gz", hash = "sha256:925c0609e0a8a6488ec4a55b19d485882cf72223b2b19fe2359a50fddd813c9c"}, 436 | ] 437 | 438 | [package.extras] 439 | docs = ["mkdocs (>=1.1,<1.2)", "mkdocs-material (>=6.2,<6.3)"] 440 | 441 | [[package]] 442 | name = "typing-extensions" 443 | version = "4.12.2" 444 | description = "Backported and Experimental Type Hints for Python 3.8+" 445 | optional = false 446 | python-versions = ">=3.8" 447 | files = [ 448 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 449 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 450 | ] 451 | 452 | [[package]] 453 | name = "tzdata" 454 | version = "2024.1" 455 | description = "Provider of IANA time zone data" 456 | optional = false 457 | python-versions = ">=2" 458 | files = [ 459 | {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, 460 | {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, 461 | ] 462 | 463 | [[package]] 464 | name = "urllib3" 465 | version = "2.2.2" 466 | description = "HTTP library with thread-safe connection pooling, file post, and more." 467 | optional = false 468 | python-versions = ">=3.8" 469 | files = [ 470 | {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, 471 | {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, 472 | ] 473 | 474 | [package.extras] 475 | brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] 476 | h2 = ["h2 (>=4,<5)"] 477 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 478 | zstd = ["zstandard (>=0.18.0)"] 479 | 480 | [[package]] 481 | name = "whitenoise" 482 | version = "6.7.0" 483 | description = "Radically simplified static file serving for WSGI applications" 484 | optional = false 485 | python-versions = ">=3.8" 486 | files = [ 487 | {file = "whitenoise-6.7.0-py3-none-any.whl", hash = "sha256:a1ae85e01fdc9815d12fa33f17765bc132ed2c54fa76daf9e39e879dd93566f6"}, 488 | {file = "whitenoise-6.7.0.tar.gz", hash = "sha256:58c7a6cd811e275a6c91af22e96e87da0b1109e9a53bb7464116ef4c963bf636"}, 489 | ] 490 | 491 | [package.extras] 492 | brotli = ["brotli"] 493 | 494 | [metadata] 495 | lock-version = "2.0" 496 | python-versions = "^3.11" 497 | content-hash = "6d054eb17040dd00e845a0e3fe63fbe7beb3a5880044be6fffa50764aaf6ead3" 498 | -------------------------------------------------------------------------------- /server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "djangopress" 3 | version = "0.0.0" 4 | description = "" 5 | authors = ["Your Name "] 6 | packages = [{ include = "djangopress" }] 7 | 8 | [tool.poetry.dependencies] 9 | python = "<4.0" 10 | django = "^5.0" 11 | psycopg = "^3.1.16" 12 | dj-database-url = "^2.1.0" 13 | gunicorn = "^21.2.0" 14 | sentry-sdk = {extras = ["django"], version = "^2.3.1"} 15 | isort = "^5.13.2" 16 | django-bridge = "^0.3" 17 | whitenoise = "^6.6.0" 18 | pillow = "^10.3.0" 19 | filetype = "^1.2.0" 20 | 21 | [tool.poetry.group.dev.dependencies] 22 | black = "^23.12.1" 23 | 24 | [tool.isort] 25 | profile = "black" 26 | known_first_party = ["djangopress"] 27 | 28 | [build-system] 29 | requires = ["poetry-core"] 30 | build-backend = "poetry.core.masonry.api" 31 | --------------------------------------------------------------------------------