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.
Files changed (50) hide show
  1. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/.gitignore +1 -0
  2. simple_module_branding-0.0.28/PKG-INFO +142 -0
  3. simple_module_branding-0.0.28/README.md +126 -0
  4. simple_module_branding-0.0.28/branding/components/BannerField.tsx +75 -0
  5. simple_module_branding-0.0.28/branding/components/FooterCard.tsx +76 -0
  6. simple_module_branding-0.0.28/branding/components/FooterEditor.tsx +139 -0
  7. simple_module_branding-0.0.28/branding/components/ImageField.tsx +81 -0
  8. simple_module_branding-0.0.28/branding/components/LinkRows.tsx +92 -0
  9. simple_module_branding-0.0.28/branding/components/PresetField.tsx +61 -0
  10. simple_module_branding-0.0.28/branding/constants.py +118 -0
  11. simple_module_branding-0.0.28/branding/contracts/footer.py +87 -0
  12. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/contracts/schemas.py +27 -0
  13. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/deps.py +7 -1
  14. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/api.py +46 -15
  15. simple_module_branding-0.0.28/branding/endpoints/assets.py +110 -0
  16. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/views.py +9 -1
  17. simple_module_branding-0.0.28/branding/footer.py +98 -0
  18. simple_module_branding-0.0.28/branding/images.py +98 -0
  19. simple_module_branding-0.0.28/branding/locales/en.json +52 -0
  20. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/module.py +21 -2
  21. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/pages/Manage.tsx +84 -64
  22. simple_module_branding-0.0.28/branding/presets.py +67 -0
  23. simple_module_branding-0.0.28/branding/service.py +151 -0
  24. simple_module_branding-0.0.28/branding/services.py +35 -0
  25. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/settings.py +30 -0
  26. simple_module_branding-0.0.28/branding/shared_props.py +88 -0
  27. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/pyproject.toml +6 -6
  28. simple_module_branding-0.0.28/tests/test_asset_lifecycle.py +147 -0
  29. simple_module_branding-0.0.28/tests/test_banner.py +131 -0
  30. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tests/test_branding.py +15 -4
  31. simple_module_branding-0.0.28/tests/test_footer.py +221 -0
  32. simple_module_branding-0.0.28/tests/test_images.py +127 -0
  33. simple_module_branding-0.0.28/tests/test_logo_dark.py +169 -0
  34. simple_module_branding-0.0.28/tests/test_presets.py +116 -0
  35. simple_module_branding-0.0.28/tests/test_public_assets.py +200 -0
  36. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tsconfig.json +0 -1
  37. simple_module_branding-0.0.26/PKG-INFO +0 -91
  38. simple_module_branding-0.0.26/README.md +0 -75
  39. simple_module_branding-0.0.26/branding/constants.py +0 -79
  40. simple_module_branding-0.0.26/branding/locales/en.json +0 -27
  41. simple_module_branding-0.0.26/branding/service.py +0 -62
  42. simple_module_branding-0.0.26/branding/services.py +0 -20
  43. simple_module_branding-0.0.26/branding/shared_props.py +0 -45
  44. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/__init__.py +0 -0
  45. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/components/DesignPackField.tsx +0 -0
  46. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/contracts/__init__.py +0 -0
  47. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/endpoints/__init__.py +0 -0
  48. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/branding/py.typed +0 -0
  49. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/package.json +0 -0
  50. {simple_module_branding-0.0.26 → simple_module_branding-0.0.28}/tests/test_design_pack.py +0 -0
@@ -45,6 +45,7 @@ docs/.vitepress/dist/
45
45
  host/client_app/modules.manifest.json
46
46
  host/client_app/modules.generated.ts
47
47
  host/client_app/modules.generated.css
48
+ host/client_app/modules.assets.json
48
49
 
49
50
  # Worktrees
50
51
  .worktrees/
@@ -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
+ | ![Branding admin](../../docs/branding/screenshots/01-branding-admin-default.png) | ![Branding applied](../../docs/branding/screenshots/02-branding-admin-applied.png) | ![Branded dashboard](../../docs/branding/screenshots/03-branded-dashboard.png) |
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
+ | ![Branding admin](../../docs/branding/screenshots/01-branding-admin-default.png) | ![Branding applied](../../docs/branding/screenshots/02-branding-admin-applied.png) | ![Branded dashboard](../../docs/branding/screenshots/03-branded-dashboard.png) |
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
+ }