├── .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 | [](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 | {
128 | e.preventDefault();
129 | confirmUnload();
130 | }}
131 | >
132 | Leave without saving
133 |
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 | {
159 | const newFiles = Array.from(files);
160 | if (newFiles.length === 1) {
161 | setFiles(null);
162 | } else {
163 | newFiles.splice(idx, 1);
164 | setFiles(newFiles);
165 | }
166 | }}
167 | >
168 |
169 |
170 |
171 | ))}
172 |
173 | ) : (
174 |
175 |
176 |
177 | Drag and drop your file or{" "}
178 | {
183 | if (forwardedRef.current) {
184 | return forwardedRef.current.click();
185 | }
186 | return null;
187 | }}
188 | >
189 | browse
190 |
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 |
38 | {this.choices.map((choice) => {choice.label} )}
39 |
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 |
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 |
92 |
93 | )}
94 |
95 |
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 |
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 | }
110 | size="sm"
111 | onClick={() => {
112 | // store a reference to our file handle
113 | async function getFile() {
114 | // open file picker
115 | const fileHandles = await showOpenFilePicker({
116 | multiple: true,
117 | });
118 |
119 | for (const fileHandle of fileHandles) {
120 | fileHandle.getFile().then((file) => {
121 | uploadFile(file, upload_url, csrfToken).then(
122 | () => {
123 | void refreshProps();
124 | },
125 | );
126 | });
127 | }
128 | }
129 |
130 | void getFile();
131 | }}
132 | >
133 | Upload
134 |
135 | )}
136 | >
137 |
138 | {assets.map((asset) => (
139 |
140 |
141 |
142 |
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 |
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 | }
38 | size="sm"
39 | onClick={() =>
40 | openOverlay(
41 | "/posts/add/",
42 | (content) => {content} ,
43 | {
44 | onClose: () => {
45 | // Refresh props so new post pops up in listing
46 | refreshProps();
47 | },
48 | }
49 | )
50 | }
51 | >
52 | Add Post
53 |
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 | Post
68 |
69 |
70 |
71 | {posts.map((post) => (
72 |
73 |
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 |
110 |
111 | ))}
112 |
113 |
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 |
--------------------------------------------------------------------------------