simple-module-branding 0.0.26__tar.gz → 0.0.28__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/.gitignore +1 -0
- simple_module_branding-0.0.28/PKG-INFO +142 -0
- simple_module_branding-0.0.28/README.md +126 -0
- simple_module_branding-0.0.28/branding/components/BannerField.tsx +75 -0
- simple_module_branding-0.0.28/branding/components/FooterCard.tsx +76 -0
- simple_module_branding-0.0.28/branding/components/FooterEditor.tsx +139 -0
- simple_module_branding-0.0.28/branding/components/ImageField.tsx +81 -0
- simple_module_branding-0.0.28/branding/components/LinkRows.tsx +92 -0
- simple_module_branding-0.0.28/branding/components/PresetField.tsx +61 -0
- simple_module_branding-0.0.28/branding/constants.py +118 -0
- simple_module_branding-0.0.28/branding/contracts/footer.py +87 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/contracts/schemas.py +27 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/deps.py +7 -1
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/api.py +46 -15
- simple_module_branding-0.0.28/branding/endpoints/assets.py +110 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/views.py +9 -1
- simple_module_branding-0.0.28/branding/footer.py +98 -0
- simple_module_branding-0.0.28/branding/images.py +98 -0
- simple_module_branding-0.0.28/branding/locales/en.json +52 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/module.py +21 -2
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/pages/Manage.tsx +84 -64
- simple_module_branding-0.0.28/branding/presets.py +67 -0
- simple_module_branding-0.0.28/branding/service.py +151 -0
- simple_module_branding-0.0.28/branding/services.py +35 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/settings.py +30 -0
- simple_module_branding-0.0.28/branding/shared_props.py +88 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/pyproject.toml +6 -6
- simple_module_branding-0.0.28/tests/test_asset_lifecycle.py +147 -0
- simple_module_branding-0.0.28/tests/test_banner.py +131 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tests/test_branding.py +15 -4
- simple_module_branding-0.0.28/tests/test_footer.py +221 -0
- simple_module_branding-0.0.28/tests/test_images.py +127 -0
- simple_module_branding-0.0.28/tests/test_logo_dark.py +169 -0
- simple_module_branding-0.0.28/tests/test_presets.py +116 -0
- simple_module_branding-0.0.28/tests/test_public_assets.py +200 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tsconfig.json +0 -1
- simple_module_branding-0.0.26/PKG-INFO +0 -91
- simple_module_branding-0.0.26/README.md +0 -75
- simple_module_branding-0.0.26/branding/constants.py +0 -79
- simple_module_branding-0.0.26/branding/locales/en.json +0 -27
- simple_module_branding-0.0.26/branding/service.py +0 -62
- simple_module_branding-0.0.26/branding/services.py +0 -20
- simple_module_branding-0.0.26/branding/shared_props.py +0 -45
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/__init__.py +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/components/DesignPackField.tsx +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/contracts/__init__.py +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/__init__.py +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/py.typed +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/package.json +0 -0
- {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tests/test_design_pack.py +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_branding
|
|
3
|
+
Version: 0.0.28
|
|
4
|
+
Summary: Customisable application branding (name, logo, favicon, primary colour) for simple_module apps
|
|
5
|
+
Project-URL: Repository, https://github.com/antosubash/simple_module_python
|
|
6
|
+
Author-email: Anto Subash <antosubash@live.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: branding,simple-module,theming,white-label
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: simple-module-core==0.0.28
|
|
11
|
+
Requires-Dist: simple-module-db==0.0.28
|
|
12
|
+
Requires-Dist: simple-module-file-storage==0.0.28
|
|
13
|
+
Requires-Dist: simple-module-hosting==0.0.28
|
|
14
|
+
Requires-Dist: simple-module-settings==0.0.28
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# simple_module_branding
|
|
18
|
+
|
|
19
|
+
Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.
|
|
20
|
+
|
|
21
|
+
An administrator can set the **application name**, **logo** (plus an optional
|
|
22
|
+
**dark-background variant**), **favicon** and **primary brand colour** from the
|
|
23
|
+
admin UI (`/branding`), and those values are
|
|
24
|
+
applied everywhere the framework would otherwise show the default identity —
|
|
25
|
+
the sidebar/header logo and name, the browser tab title, the favicon, and the
|
|
26
|
+
primary accent colour.
|
|
27
|
+
|
|
28
|
+
## Screenshots
|
|
29
|
+
|
|
30
|
+
The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
|
|
31
|
+
(custom logo + name + primary colour) across the sidebar and dashboard:
|
|
32
|
+
|
|
33
|
+
| Admin page | Branding applied | Across the app |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
|  |  |  |
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
The module ships with the default app. To add it to a custom host, declare it
|
|
40
|
+
as a dependency and let entry-point discovery pick it up:
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
# host/pyproject.toml
|
|
44
|
+
dependencies = ["simple_module_branding"]
|
|
45
|
+
|
|
46
|
+
[tool.uv.sources]
|
|
47
|
+
simple_module_branding = { workspace = true }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
|
|
51
|
+
modules to be installed too.
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
1. Sign in as an admin and open **Branding** in the sidebar (or visit
|
|
56
|
+
`/branding`).
|
|
57
|
+
2. Set the application name, pick a primary colour, and upload a logo and/or
|
|
58
|
+
favicon. Changes apply immediately across the app.
|
|
59
|
+
|
|
60
|
+
Programmatically, the current branding is available on every page through the
|
|
61
|
+
`branding` Inertia shared prop (`appName`, `primaryColor`, `designPack`,
|
|
62
|
+
`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`, `footer`). `banner` and
|
|
63
|
+
`footer` are `null` when unconfigured, which is what makes the frontend fall
|
|
64
|
+
back to rendering nothing and to the framework footer respectively. For a dark
|
|
65
|
+
surface use
|
|
66
|
+
`darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies
|
|
67
|
+
the `logoDarkUrl → logoUrl` fallback in one place.
|
|
68
|
+
|
|
69
|
+
## How it works
|
|
70
|
+
|
|
71
|
+
- **Storage.** The four values are persisted via the `settings` module's store
|
|
72
|
+
(SYSTEM scope) — there is no branding database table. They hydrate into
|
|
73
|
+
`app.state.branding.settings` at boot and hot-swap on save.
|
|
74
|
+
- **Images.** Logo and favicon uploads are stored through the `file_storage`
|
|
75
|
+
module (referenced by UUID). Branding serves them back from its own
|
|
76
|
+
**anonymous** routes, `GET /api/branding/logo` and `GET /api/branding/favicon`
|
|
77
|
+
— `file_storage`'s download endpoint requires `file-storage.download`, which
|
|
78
|
+
no logged-out visitor has, and the sign-in page is exactly where the logo
|
|
79
|
+
must appear. Only the two ids currently held in branding settings are served,
|
|
80
|
+
so this is not a way to read arbitrary files. Uploading and clearing on those
|
|
81
|
+
same paths stay behind `branding.manage` (the exemption is GET-only).
|
|
82
|
+
- **Caching.** The published URL carries `?v=<file id>`; a replaced image is a
|
|
83
|
+
new `file_storage` id, so the URL is content-addressed. Versioned requests
|
|
84
|
+
are served `public, max-age=31536000, immutable`; a request without a usable
|
|
85
|
+
version gets `public, max-age=3600` so it self-corrects, and a 404 is never
|
|
86
|
+
cached.
|
|
87
|
+
- **Announcement banner.** A message plus a severity (`info` / `warning` /
|
|
88
|
+
`danger`) rendered above every shell — app, public and auth — because an
|
|
89
|
+
outage notice is most useful to people who cannot sign in. An empty message
|
|
90
|
+
hides it. Severity colours are semantic, not brand-tinted: a warning wearing
|
|
91
|
+
the deployment's accent colour stops reading as a warning.
|
|
92
|
+
- **Presets.** One-click looks (`POST /api/branding/presets/{key}`), applied
|
|
93
|
+
through the ordinary update path so every validator still runs. A preset only
|
|
94
|
+
ever sets *appearance* (`PRESET_FIELDS` — primary colour, design pack); it
|
|
95
|
+
can never overwrite the app name, an uploaded logo or a live banner, and
|
|
96
|
+
`BrandingPreset` rejects any other field at construction.
|
|
97
|
+
- **Configurable footer.** Tagline, copyright owner, caption, up to 6 columns
|
|
98
|
+
of 8 links, and up to 8 social links (`PUT /api/branding/footer`, whole-object
|
|
99
|
+
replace). Link URLs are restricted to http(s) or a single-leading-slash app
|
|
100
|
+
path — `javascript:` and `data:` are refused, and `//host` is treated as the
|
|
101
|
+
off-site absolute URL it is rather than a path. With nothing configured the
|
|
102
|
+
framework's built-in footer renders unchanged.
|
|
103
|
+
- **Dark-background logo.** The sidebar and mobile bar sit on a near-black
|
|
104
|
+
surface in every theme, while the sign-in card and public page are light — so
|
|
105
|
+
a single logo cannot read on both. Uploading a *Logo (dark backgrounds)*
|
|
106
|
+
variant swaps it in on those surfaces only. It is optional: with none set the
|
|
107
|
+
shared prop reports `logoDarkUrl: null` and the frontend falls back to
|
|
108
|
+
`logoUrl`, so single-logo deployments look exactly as they did.
|
|
109
|
+
- **Lifecycle.** Replacing or clearing an image deletes the file it stopped
|
|
110
|
+
referencing, so repeated logo tweaks don't leave orphans in `file_storage`.
|
|
111
|
+
Cleanup is best effort: the setting change has already been persisted, so a
|
|
112
|
+
storage fault is logged rather than failing an otherwise-successful rebrand.
|
|
113
|
+
- **Upload validation.** PNG, JPEG, WEBP, GIF and ICO up to 2 MB. The declared
|
|
114
|
+
content-type is caller-controlled, so the first bytes are also checked
|
|
115
|
+
against each format's magic number — a payload renamed `logo.png` is
|
|
116
|
+
rejected. SVG is excluded on purpose: it is an XML document that can carry
|
|
117
|
+
`<script>`, so serving one from the app's origin would be stored XSS.
|
|
118
|
+
- **Delivery.** A registered Inertia shared-props provider injects a `branding`
|
|
119
|
+
block into every page's shared props (authenticated *and* guest), which the
|
|
120
|
+
frontend reads for the name, logo, favicon and colour.
|
|
121
|
+
|
|
122
|
+
## Permissions
|
|
123
|
+
|
|
124
|
+
- `branding.view` — view the branding admin page.
|
|
125
|
+
- `branding.manage` — change branding (name, colour, logo, favicon).
|
|
126
|
+
|
|
127
|
+
The logo and favicon **GET** routes are anonymous by design (registered through
|
|
128
|
+
the `register_public_routes` hook); everything else requires a permission.
|
|
129
|
+
|
|
130
|
+
## Dependencies
|
|
131
|
+
|
|
132
|
+
Depends on the `Settings` and `FileStorage` modules.
|
|
133
|
+
|
|
134
|
+
## Notes
|
|
135
|
+
|
|
136
|
+
- Branding is currently SYSTEM-scoped (one identity per deployment). The
|
|
137
|
+
settings store already supports tenant/user scope, leaving room for
|
|
138
|
+
per-tenant branding later.
|
|
139
|
+
- The primary colour overrides the `--primary` / `--sidebar-primary` CSS
|
|
140
|
+
variables from a single hex; the full OKLCH colour scale is not regenerated.
|
|
141
|
+
|
|
142
|
+
License: MIT
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# simple_module_branding
|
|
2
|
+
|
|
3
|
+
Customisable application branding for [simple_module_python](https://github.com/antosubash/simple_module_python) apps.
|
|
4
|
+
|
|
5
|
+
An administrator can set the **application name**, **logo** (plus an optional
|
|
6
|
+
**dark-background variant**), **favicon** and **primary brand colour** from the
|
|
7
|
+
admin UI (`/branding`), and those values are
|
|
8
|
+
applied everywhere the framework would otherwise show the default identity —
|
|
9
|
+
the sidebar/header logo and name, the browser tab title, the favicon, and the
|
|
10
|
+
primary accent colour.
|
|
11
|
+
|
|
12
|
+
## Screenshots
|
|
13
|
+
|
|
14
|
+
The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
|
|
15
|
+
(custom logo + name + primary colour) across the sidebar and dashboard:
|
|
16
|
+
|
|
17
|
+
| Admin page | Branding applied | Across the app |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
|  |  |  |
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
The module ships with the default app. To add it to a custom host, declare it
|
|
24
|
+
as a dependency and let entry-point discovery pick it up:
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
# host/pyproject.toml
|
|
28
|
+
dependencies = ["simple_module_branding"]
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
simple_module_branding = { workspace = true }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
|
|
35
|
+
modules to be installed too.
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
1. Sign in as an admin and open **Branding** in the sidebar (or visit
|
|
40
|
+
`/branding`).
|
|
41
|
+
2. Set the application name, pick a primary colour, and upload a logo and/or
|
|
42
|
+
favicon. Changes apply immediately across the app.
|
|
43
|
+
|
|
44
|
+
Programmatically, the current branding is available on every page through the
|
|
45
|
+
`branding` Inertia shared prop (`appName`, `primaryColor`, `designPack`,
|
|
46
|
+
`logoUrl`, `logoDarkUrl`, `faviconUrl`, `banner`, `footer`). `banner` and
|
|
47
|
+
`footer` are `null` when unconfigured, which is what makes the frontend fall
|
|
48
|
+
back to rendering nothing and to the framework footer respectively. For a dark
|
|
49
|
+
surface use
|
|
50
|
+
`darkSurfaceLogo(branding)` from `@simple-module-py/ui/lib/brand`, which applies
|
|
51
|
+
the `logoDarkUrl → logoUrl` fallback in one place.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
- **Storage.** The four values are persisted via the `settings` module's store
|
|
56
|
+
(SYSTEM scope) — there is no branding database table. They hydrate into
|
|
57
|
+
`app.state.branding.settings` at boot and hot-swap on save.
|
|
58
|
+
- **Images.** Logo and favicon uploads are stored through the `file_storage`
|
|
59
|
+
module (referenced by UUID). Branding serves them back from its own
|
|
60
|
+
**anonymous** routes, `GET /api/branding/logo` and `GET /api/branding/favicon`
|
|
61
|
+
— `file_storage`'s download endpoint requires `file-storage.download`, which
|
|
62
|
+
no logged-out visitor has, and the sign-in page is exactly where the logo
|
|
63
|
+
must appear. Only the two ids currently held in branding settings are served,
|
|
64
|
+
so this is not a way to read arbitrary files. Uploading and clearing on those
|
|
65
|
+
same paths stay behind `branding.manage` (the exemption is GET-only).
|
|
66
|
+
- **Caching.** The published URL carries `?v=<file id>`; a replaced image is a
|
|
67
|
+
new `file_storage` id, so the URL is content-addressed. Versioned requests
|
|
68
|
+
are served `public, max-age=31536000, immutable`; a request without a usable
|
|
69
|
+
version gets `public, max-age=3600` so it self-corrects, and a 404 is never
|
|
70
|
+
cached.
|
|
71
|
+
- **Announcement banner.** A message plus a severity (`info` / `warning` /
|
|
72
|
+
`danger`) rendered above every shell — app, public and auth — because an
|
|
73
|
+
outage notice is most useful to people who cannot sign in. An empty message
|
|
74
|
+
hides it. Severity colours are semantic, not brand-tinted: a warning wearing
|
|
75
|
+
the deployment's accent colour stops reading as a warning.
|
|
76
|
+
- **Presets.** One-click looks (`POST /api/branding/presets/{key}`), applied
|
|
77
|
+
through the ordinary update path so every validator still runs. A preset only
|
|
78
|
+
ever sets *appearance* (`PRESET_FIELDS` — primary colour, design pack); it
|
|
79
|
+
can never overwrite the app name, an uploaded logo or a live banner, and
|
|
80
|
+
`BrandingPreset` rejects any other field at construction.
|
|
81
|
+
- **Configurable footer.** Tagline, copyright owner, caption, up to 6 columns
|
|
82
|
+
of 8 links, and up to 8 social links (`PUT /api/branding/footer`, whole-object
|
|
83
|
+
replace). Link URLs are restricted to http(s) or a single-leading-slash app
|
|
84
|
+
path — `javascript:` and `data:` are refused, and `//host` is treated as the
|
|
85
|
+
off-site absolute URL it is rather than a path. With nothing configured the
|
|
86
|
+
framework's built-in footer renders unchanged.
|
|
87
|
+
- **Dark-background logo.** The sidebar and mobile bar sit on a near-black
|
|
88
|
+
surface in every theme, while the sign-in card and public page are light — so
|
|
89
|
+
a single logo cannot read on both. Uploading a *Logo (dark backgrounds)*
|
|
90
|
+
variant swaps it in on those surfaces only. It is optional: with none set the
|
|
91
|
+
shared prop reports `logoDarkUrl: null` and the frontend falls back to
|
|
92
|
+
`logoUrl`, so single-logo deployments look exactly as they did.
|
|
93
|
+
- **Lifecycle.** Replacing or clearing an image deletes the file it stopped
|
|
94
|
+
referencing, so repeated logo tweaks don't leave orphans in `file_storage`.
|
|
95
|
+
Cleanup is best effort: the setting change has already been persisted, so a
|
|
96
|
+
storage fault is logged rather than failing an otherwise-successful rebrand.
|
|
97
|
+
- **Upload validation.** PNG, JPEG, WEBP, GIF and ICO up to 2 MB. The declared
|
|
98
|
+
content-type is caller-controlled, so the first bytes are also checked
|
|
99
|
+
against each format's magic number — a payload renamed `logo.png` is
|
|
100
|
+
rejected. SVG is excluded on purpose: it is an XML document that can carry
|
|
101
|
+
`<script>`, so serving one from the app's origin would be stored XSS.
|
|
102
|
+
- **Delivery.** A registered Inertia shared-props provider injects a `branding`
|
|
103
|
+
block into every page's shared props (authenticated *and* guest), which the
|
|
104
|
+
frontend reads for the name, logo, favicon and colour.
|
|
105
|
+
|
|
106
|
+
## Permissions
|
|
107
|
+
|
|
108
|
+
- `branding.view` — view the branding admin page.
|
|
109
|
+
- `branding.manage` — change branding (name, colour, logo, favicon).
|
|
110
|
+
|
|
111
|
+
The logo and favicon **GET** routes are anonymous by design (registered through
|
|
112
|
+
the `register_public_routes` hook); everything else requires a permission.
|
|
113
|
+
|
|
114
|
+
## Dependencies
|
|
115
|
+
|
|
116
|
+
Depends on the `Settings` and `FileStorage` modules.
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
- Branding is currently SYSTEM-scoped (one identity per deployment). The
|
|
121
|
+
settings store already supports tenant/user scope, leaving room for
|
|
122
|
+
per-tenant branding later.
|
|
123
|
+
- The primary colour overrides the `--primary` / `--sidebar-primary` CSS
|
|
124
|
+
variables from a single hex; the full OKLCH colour scale is not regenerated.
|
|
125
|
+
|
|
126
|
+
License: MIT
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
3
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
4
|
+
import {
|
|
5
|
+
Select,
|
|
6
|
+
SelectContent,
|
|
7
|
+
SelectItem,
|
|
8
|
+
SelectTrigger,
|
|
9
|
+
SelectValue,
|
|
10
|
+
} from '@simple-module-py/ui/components/ui/select';
|
|
11
|
+
|
|
12
|
+
/** Mirrors `BANNER_SEVERITIES` in `branding/constants.py`. */
|
|
13
|
+
export const BANNER_SEVERITIES = ['info', 'warning', 'danger'] as const;
|
|
14
|
+
export type BannerSeverity = (typeof BANNER_SEVERITIES)[number];
|
|
15
|
+
|
|
16
|
+
/** Matches `MAX_BANNER_MESSAGE_LEN`, so the server never has to reject length. */
|
|
17
|
+
export const MAX_BANNER_MESSAGE = 500;
|
|
18
|
+
|
|
19
|
+
interface BannerFieldProps {
|
|
20
|
+
message: string;
|
|
21
|
+
severity: BannerSeverity;
|
|
22
|
+
onMessageChange: (next: string) => void;
|
|
23
|
+
onSeverityChange: (next: BannerSeverity) => void;
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Site-wide announcement bar: message + severity. Empty message hides it. */
|
|
28
|
+
export function BannerField({
|
|
29
|
+
message,
|
|
30
|
+
severity,
|
|
31
|
+
onMessageChange,
|
|
32
|
+
onSeverityChange,
|
|
33
|
+
disabled,
|
|
34
|
+
}: BannerFieldProps) {
|
|
35
|
+
const { t } = useT();
|
|
36
|
+
const severityLabels: Record<BannerSeverity, string> = {
|
|
37
|
+
info: t(keys.branding.manage.banner_severity_info),
|
|
38
|
+
warning: t(keys.branding.manage.banner_severity_warning),
|
|
39
|
+
danger: t(keys.branding.manage.banner_severity_danger),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
<Label htmlFor="banner_message">{t(keys.branding.manage.banner_label)}</Label>
|
|
45
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
46
|
+
<Input
|
|
47
|
+
id="banner_message"
|
|
48
|
+
value={message}
|
|
49
|
+
maxLength={MAX_BANNER_MESSAGE}
|
|
50
|
+
disabled={disabled}
|
|
51
|
+
placeholder={t(keys.branding.manage.banner_placeholder)}
|
|
52
|
+
onChange={(e) => onMessageChange(e.target.value)}
|
|
53
|
+
className="min-w-60 flex-1"
|
|
54
|
+
/>
|
|
55
|
+
<Select
|
|
56
|
+
value={severity}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
onValueChange={(next) => onSeverityChange(next as BannerSeverity)}
|
|
59
|
+
>
|
|
60
|
+
<SelectTrigger id="banner_severity" className="w-40" aria-label="Banner severity">
|
|
61
|
+
<SelectValue />
|
|
62
|
+
</SelectTrigger>
|
|
63
|
+
<SelectContent>
|
|
64
|
+
{BANNER_SEVERITIES.map((value) => (
|
|
65
|
+
<SelectItem key={value} value={value}>
|
|
66
|
+
{severityLabels[value]}
|
|
67
|
+
</SelectItem>
|
|
68
|
+
))}
|
|
69
|
+
</SelectContent>
|
|
70
|
+
</Select>
|
|
71
|
+
</div>
|
|
72
|
+
<p className="text-xs text-muted-foreground">{t(keys.branding.manage.banner_help)}</p>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@simple-module-py/ui/components/ui/card';
|
|
4
|
+
import type { FooterShared } from '@simple-module-py/ui/types';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { EMPTY_FOOTER, type FooterDraft, FooterEditor } from './FooterEditor';
|
|
7
|
+
import { newRowId, stripIds } from './LinkRows';
|
|
8
|
+
|
|
9
|
+
/** Payload shape of `PUT /api/branding/footer` (snake_case, like the DTO). */
|
|
10
|
+
export interface FooterPayload {
|
|
11
|
+
tagline: string;
|
|
12
|
+
copyright_owner: string;
|
|
13
|
+
note: string;
|
|
14
|
+
columns: { title: string; links: { label: string; href: string }[] }[];
|
|
15
|
+
social_links: { label: string; href: string }[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FooterCardProps {
|
|
19
|
+
/** Current server-side footer, or null when none is configured. */
|
|
20
|
+
initial: FooterShared | null;
|
|
21
|
+
disabled: boolean;
|
|
22
|
+
busy: boolean;
|
|
23
|
+
onSave: (payload: FooterPayload) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rows get a client-only id so React keys stay stable across add/remove. */
|
|
27
|
+
function toDraft(footer: FooterShared | null): FooterDraft {
|
|
28
|
+
if (!footer) return EMPTY_FOOTER;
|
|
29
|
+
return {
|
|
30
|
+
tagline: footer.tagline,
|
|
31
|
+
copyrightOwner: footer.copyrightOwner,
|
|
32
|
+
note: footer.note,
|
|
33
|
+
columns: footer.columns.map((c) => ({
|
|
34
|
+
id: newRowId(),
|
|
35
|
+
title: c.title,
|
|
36
|
+
links: c.links.map((l) => ({ id: newRowId(), ...l })),
|
|
37
|
+
})),
|
|
38
|
+
socialLinks: footer.socialLinks.map((l) => ({ id: newRowId(), ...l })),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The footer section of the branding page — its own card, its own draft state
|
|
44
|
+
* and its own save, because a footer edit replaces the whole structure and is
|
|
45
|
+
* independent of the identity fields above it.
|
|
46
|
+
*/
|
|
47
|
+
export function FooterCard({ initial, disabled, busy, onSave }: FooterCardProps) {
|
|
48
|
+
const { t } = useT();
|
|
49
|
+
const [draft, setDraft] = useState<FooterDraft>(() => toDraft(initial));
|
|
50
|
+
|
|
51
|
+
const save = () =>
|
|
52
|
+
onSave({
|
|
53
|
+
tagline: draft.tagline,
|
|
54
|
+
copyright_owner: draft.copyrightOwner,
|
|
55
|
+
note: draft.note,
|
|
56
|
+
columns: stripIds(draft.columns).map((c) => ({
|
|
57
|
+
title: c.title,
|
|
58
|
+
links: stripIds(c.links),
|
|
59
|
+
})),
|
|
60
|
+
social_links: stripIds(draft.socialLinks),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Card className="lg:col-span-2">
|
|
65
|
+
<CardHeader>
|
|
66
|
+
<CardTitle>{t(keys.branding.manage.footer_title)}</CardTitle>
|
|
67
|
+
</CardHeader>
|
|
68
|
+
<CardContent className="space-y-4">
|
|
69
|
+
<FooterEditor value={draft} onChange={setDraft} disabled={disabled} />
|
|
70
|
+
<Button type="button" disabled={disabled} onClick={save}>
|
|
71
|
+
{busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.footer_save_button)}
|
|
72
|
+
</Button>
|
|
73
|
+
</CardContent>
|
|
74
|
+
</Card>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
4
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
5
|
+
import { type EditableLink, LinkRows, newRowId } from './LinkRows';
|
|
6
|
+
|
|
7
|
+
/** Mirror the server limits in `branding/footer.py`. */
|
|
8
|
+
export const MAX_COLUMNS = 6;
|
|
9
|
+
export const MAX_LINKS_PER_COLUMN = 8;
|
|
10
|
+
export const MAX_SOCIAL_LINKS = 8;
|
|
11
|
+
|
|
12
|
+
export interface EditableColumn {
|
|
13
|
+
/** Client-only key; see LinkRows. Stripped before sending. */
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
links: EditableLink[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FooterDraft {
|
|
20
|
+
tagline: string;
|
|
21
|
+
copyrightOwner: string;
|
|
22
|
+
note: string;
|
|
23
|
+
columns: EditableColumn[];
|
|
24
|
+
socialLinks: EditableLink[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const EMPTY_FOOTER: FooterDraft = {
|
|
28
|
+
tagline: '',
|
|
29
|
+
copyrightOwner: '',
|
|
30
|
+
note: '',
|
|
31
|
+
columns: [],
|
|
32
|
+
socialLinks: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
interface FooterEditorProps {
|
|
36
|
+
value: FooterDraft;
|
|
37
|
+
onChange: (next: FooterDraft) => void;
|
|
38
|
+
disabled: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Multi-column footer builder: brand text, link columns and a social row. */
|
|
42
|
+
export function FooterEditor({ value, onChange, disabled }: FooterEditorProps) {
|
|
43
|
+
const { t } = useT();
|
|
44
|
+
const patch = (next: Partial<FooterDraft>) => onChange({ ...value, ...next });
|
|
45
|
+
|
|
46
|
+
const updateColumn = (id: string, next: Partial<EditableColumn>) =>
|
|
47
|
+
patch({ columns: value.columns.map((c) => (c.id === id ? { ...c, ...next } : c)) });
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-4">
|
|
51
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
52
|
+
<div className="space-y-1.5">
|
|
53
|
+
<Label htmlFor="footer_tagline">{t(keys.branding.manage.footer_tagline_label)}</Label>
|
|
54
|
+
<Input
|
|
55
|
+
id="footer_tagline"
|
|
56
|
+
value={value.tagline}
|
|
57
|
+
disabled={disabled}
|
|
58
|
+
onChange={(e) => patch({ tagline: e.target.value })}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="space-y-1.5">
|
|
62
|
+
<Label htmlFor="footer_copyright">{t(keys.branding.manage.footer_copyright_label)}</Label>
|
|
63
|
+
<Input
|
|
64
|
+
id="footer_copyright"
|
|
65
|
+
value={value.copyrightOwner}
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
onChange={(e) => patch({ copyrightOwner: e.target.value })}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="space-y-1.5">
|
|
71
|
+
<Label htmlFor="footer_note">{t(keys.branding.manage.footer_note_label)}</Label>
|
|
72
|
+
<Input
|
|
73
|
+
id="footer_note"
|
|
74
|
+
value={value.note}
|
|
75
|
+
disabled={disabled}
|
|
76
|
+
onChange={(e) => patch({ note: e.target.value })}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="space-y-3">
|
|
82
|
+
{value.columns.map((column, index) => (
|
|
83
|
+
<div key={column.id} className="space-y-2 rounded-lg border p-3">
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
<Input
|
|
86
|
+
value={column.title}
|
|
87
|
+
disabled={disabled}
|
|
88
|
+
placeholder={t(keys.branding.manage.footer_column_title)}
|
|
89
|
+
onChange={(e) => updateColumn(column.id, { title: e.target.value })}
|
|
90
|
+
className="w-56 font-medium"
|
|
91
|
+
/>
|
|
92
|
+
<Button
|
|
93
|
+
type="button"
|
|
94
|
+
variant="ghost"
|
|
95
|
+
size="sm"
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
aria-label={`${t(keys.branding.manage.footer_remove_column)}: ${column.title || index + 1}`}
|
|
98
|
+
onClick={() => patch({ columns: value.columns.filter((c) => c.id !== column.id) })}
|
|
99
|
+
>
|
|
100
|
+
{t(keys.branding.manage.footer_remove_column)}
|
|
101
|
+
</Button>
|
|
102
|
+
</div>
|
|
103
|
+
<LinkRows
|
|
104
|
+
links={column.links}
|
|
105
|
+
max={MAX_LINKS_PER_COLUMN}
|
|
106
|
+
disabled={disabled}
|
|
107
|
+
onChange={(links) => updateColumn(column.id, { links })}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
))}
|
|
111
|
+
{value.columns.length < MAX_COLUMNS && (
|
|
112
|
+
<Button
|
|
113
|
+
type="button"
|
|
114
|
+
variant="outline"
|
|
115
|
+
size="sm"
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
onClick={() =>
|
|
118
|
+
patch({ columns: [...value.columns, { id: newRowId(), title: '', links: [] }] })
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{t(keys.branding.manage.footer_add_column)}
|
|
122
|
+
</Button>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<Label>{t(keys.branding.manage.footer_social_label)}</Label>
|
|
128
|
+
<LinkRows
|
|
129
|
+
links={value.socialLinks}
|
|
130
|
+
max={MAX_SOCIAL_LINKS}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
onChange={(socialLinks) => patch({ socialLinks })}
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<p className="text-xs text-muted-foreground">{t(keys.branding.manage.footer_help)}</p>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
2
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
3
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
4
|
+
import { type ChangeEvent, useRef } from 'react';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mirrors the server allow-list in `branding/images.py`. SVG is absent on
|
|
8
|
+
* purpose — it is an XML document that can carry <script>, so the server
|
|
9
|
+
* rejects it; offering it in the picker would only produce a 415.
|
|
10
|
+
*/
|
|
11
|
+
export const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp,image/gif,image/x-icon';
|
|
12
|
+
|
|
13
|
+
interface ImageFieldProps {
|
|
14
|
+
label: string;
|
|
15
|
+
help: string;
|
|
16
|
+
url: string | null;
|
|
17
|
+
onUpload: (file: File) => void;
|
|
18
|
+
onRemove: () => void;
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
/** Preview swatch background — dark for logos meant for dark surfaces. */
|
|
21
|
+
previewClassName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One upload/replace/remove slot with a thumbnail, for a single branding image. */
|
|
25
|
+
export function ImageField({
|
|
26
|
+
label,
|
|
27
|
+
help,
|
|
28
|
+
url,
|
|
29
|
+
onUpload,
|
|
30
|
+
onRemove,
|
|
31
|
+
disabled,
|
|
32
|
+
previewClassName = 'bg-muted',
|
|
33
|
+
}: ImageFieldProps) {
|
|
34
|
+
const { t } = useT();
|
|
35
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-2">
|
|
39
|
+
<Label>{label}</Label>
|
|
40
|
+
<div className="flex items-center gap-4">
|
|
41
|
+
<div
|
|
42
|
+
className={`flex h-16 w-16 items-center justify-center overflow-hidden rounded-lg border ${previewClassName}`}
|
|
43
|
+
>
|
|
44
|
+
{url ? (
|
|
45
|
+
<img src={url} alt={label} className="h-full w-full object-contain" />
|
|
46
|
+
) : (
|
|
47
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
<div className="flex gap-2">
|
|
51
|
+
<input
|
|
52
|
+
ref={inputRef}
|
|
53
|
+
type="file"
|
|
54
|
+
accept={ACCEPTED_IMAGE_TYPES}
|
|
55
|
+
className="hidden"
|
|
56
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
57
|
+
const file = e.target.files?.[0];
|
|
58
|
+
if (file) onUpload(file);
|
|
59
|
+
e.target.value = '';
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
<Button
|
|
63
|
+
type="button"
|
|
64
|
+
variant="outline"
|
|
65
|
+
size="sm"
|
|
66
|
+
disabled={disabled}
|
|
67
|
+
onClick={() => inputRef.current?.click()}
|
|
68
|
+
>
|
|
69
|
+
{url ? t(keys.branding.manage.replace_button) : t(keys.branding.manage.upload_button)}
|
|
70
|
+
</Button>
|
|
71
|
+
{url && (
|
|
72
|
+
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={onRemove}>
|
|
73
|
+
{t(keys.branding.manage.remove_button)}
|
|
74
|
+
</Button>
|
|
75
|
+
)}
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<p className="text-xs text-muted-foreground">{help}</p>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|