simple-module-branding 0.0.24__py3-none-any.whl
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.
- branding/__init__.py +1 -0
- branding/constants.py +65 -0
- branding/contracts/__init__.py +8 -0
- branding/contracts/schemas.py +43 -0
- branding/deps.py +21 -0
- branding/endpoints/__init__.py +0 -0
- branding/endpoints/api.py +74 -0
- branding/endpoints/views.py +29 -0
- branding/locales/en.json +23 -0
- branding/module.py +77 -0
- branding/package.json +16 -0
- branding/pages/Manage.tsx +261 -0
- branding/py.typed +0 -0
- branding/service.py +61 -0
- branding/services.py +20 -0
- branding/settings.py +42 -0
- branding/shared_props.py +44 -0
- simple_module_branding-0.0.24.dist-info/METADATA +91 -0
- simple_module_branding-0.0.24.dist-info/RECORD +21 -0
- simple_module_branding-0.0.24.dist-info/WHEEL +4 -0
- simple_module_branding-0.0.24.dist-info/entry_points.txt +2 -0
branding/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Branding module."""
|
branding/constants.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Branding module constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from file_storage.constants import PATH_FILE_DOWNLOAD, ROUTE_PREFIX_API
|
|
9
|
+
|
|
10
|
+
PACKAGE: Final = "branding"
|
|
11
|
+
|
|
12
|
+
# Modules this one depends on (kept as constants so the depends_on list doesn't
|
|
13
|
+
# carry bare string literals — see scripts/check_hardcoded_strings.py).
|
|
14
|
+
_MODULE_SETTINGS: Final = "Settings"
|
|
15
|
+
_MODULE_FILE_STORAGE: Final = "FileStorage"
|
|
16
|
+
|
|
17
|
+
# Inertia page identifier. The view endpoint renders this as a literal (so the
|
|
18
|
+
# SM003/SM004 static-AST diagnostics can pair it with pages/Manage.tsx); a test
|
|
19
|
+
# asserts the literal matches this constant.
|
|
20
|
+
_PAGE_MANAGE: Final = "Branding/Manage"
|
|
21
|
+
|
|
22
|
+
PERM_VIEW: Final = "branding.view"
|
|
23
|
+
PERM_MANAGE: Final = "branding.manage"
|
|
24
|
+
|
|
25
|
+
# A #rrggbb hex colour (single source of truth for both the settings and the
|
|
26
|
+
# update-DTO validators).
|
|
27
|
+
HEX_COLOR_RE: Final = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
28
|
+
MAX_APP_NAME_LEN: Final = 60
|
|
29
|
+
|
|
30
|
+
# Image upload guard-rails (enforced before handing the file to file_storage).
|
|
31
|
+
MAX_IMAGE_BYTES: Final = 2 * 1024 * 1024 # 2 MB
|
|
32
|
+
ALLOWED_IMAGE_TYPES: Final = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"image/png",
|
|
35
|
+
"image/jpeg",
|
|
36
|
+
"image/svg+xml",
|
|
37
|
+
"image/webp",
|
|
38
|
+
"image/gif",
|
|
39
|
+
"image/x-icon",
|
|
40
|
+
"image/vnd.microsoft.icon",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# file_storage download URL, derived from file_storage's own route constants
|
|
45
|
+
# (branding depends on FileStorage) so it tracks any route change. The
|
|
46
|
+
# ``{file_id}`` placeholder is filled per stored-file id.
|
|
47
|
+
FILE_DOWNLOAD_URL: Final = ROUTE_PREFIX_API + PATH_FILE_DOWNLOAD
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clean_app_name(value: str) -> str:
|
|
51
|
+
"""Normalise + validate an app name (shared by the settings + update DTO).
|
|
52
|
+
|
|
53
|
+
The name is surfaced in HTML titles and—critically—email ``Subject``
|
|
54
|
+
headers, so control characters (notably CR/LF) must be rejected: an
|
|
55
|
+
embedded newline would otherwise pass a bare ``strip()`` and then raise
|
|
56
|
+
when set as a header, breaking every transactional email.
|
|
57
|
+
"""
|
|
58
|
+
cleaned = value.strip()
|
|
59
|
+
if not cleaned:
|
|
60
|
+
raise ValueError("app_name must not be blank")
|
|
61
|
+
if len(cleaned) > MAX_APP_NAME_LEN:
|
|
62
|
+
raise ValueError(f"app_name must be at most {MAX_APP_NAME_LEN} characters")
|
|
63
|
+
if any(ord(ch) < 0x20 for ch in cleaned):
|
|
64
|
+
raise ValueError("app_name must not contain control characters")
|
|
65
|
+
return cleaned
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""SQLModel DTOs for the Branding module — the public surface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import field_validator
|
|
6
|
+
from sqlmodel import Field, SQLModel
|
|
7
|
+
|
|
8
|
+
from branding.constants import HEX_COLOR_RE, MAX_APP_NAME_LEN, clean_app_name
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BrandingOut(SQLModel):
|
|
12
|
+
"""Current branding, with logo/favicon resolved to download URLs."""
|
|
13
|
+
|
|
14
|
+
app_name: str
|
|
15
|
+
primary_color: str = ""
|
|
16
|
+
logo_url: str | None = None
|
|
17
|
+
favicon_url: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BrandingUpdate(SQLModel):
|
|
21
|
+
"""Editable text fields. Logo/favicon are set via dedicated upload routes."""
|
|
22
|
+
|
|
23
|
+
app_name: str | None = Field(default=None, max_length=MAX_APP_NAME_LEN)
|
|
24
|
+
primary_color: str | None = Field(default=None)
|
|
25
|
+
|
|
26
|
+
@field_validator("app_name")
|
|
27
|
+
@classmethod
|
|
28
|
+
def _non_empty_name(cls, value: str | None) -> str | None:
|
|
29
|
+
# Validate here so bad input surfaces as a 422 rather than a 500 when
|
|
30
|
+
# BrandingSettings re-validates (blank, too long, or control chars —
|
|
31
|
+
# the last would otherwise break email Subject headers downstream).
|
|
32
|
+
if value is None:
|
|
33
|
+
return None
|
|
34
|
+
return clean_app_name(value)
|
|
35
|
+
|
|
36
|
+
@field_validator("primary_color")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _valid_hex(cls, value: str | None) -> str | None:
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
if value != "" and not HEX_COLOR_RE.match(value):
|
|
42
|
+
raise ValueError("primary_color must be a #rrggbb hex string or empty")
|
|
43
|
+
return value.lower()
|
branding/deps.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""FastAPI dependencies for the Branding module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
from fastapi import Depends, Request
|
|
8
|
+
from simple_module_db.deps import get_db
|
|
9
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
|
+
|
|
11
|
+
from branding.service import BrandingService
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def get_branding_service(
|
|
15
|
+
request: Request,
|
|
16
|
+
db: AsyncSession = Depends(get_db),
|
|
17
|
+
) -> BrandingService:
|
|
18
|
+
return BrandingService(request.app, db)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
BrandingServiceDep = Annotated[BrandingService, Depends(get_branding_service)]
|
|
File without changes
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""REST API endpoints for Branding (JSON). All writes require branding.manage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile
|
|
6
|
+
from file_storage.deps import get_file_storage_service
|
|
7
|
+
from file_storage.service import FileStorageService
|
|
8
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
9
|
+
|
|
10
|
+
from branding import constants
|
|
11
|
+
from branding.contracts.schemas import BrandingOut, BrandingUpdate
|
|
12
|
+
from branding.deps import BrandingServiceDep
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
_MANAGE = Depends(RequiresPermission(constants.PERM_MANAGE))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_image(file: UploadFile) -> None:
|
|
20
|
+
if file.content_type not in constants.ALLOWED_IMAGE_TYPES:
|
|
21
|
+
raise HTTPException(
|
|
22
|
+
status_code=415,
|
|
23
|
+
detail=f"Unsupported image type {file.content_type!r}.",
|
|
24
|
+
)
|
|
25
|
+
if file.size is not None and file.size > constants.MAX_IMAGE_BYTES:
|
|
26
|
+
raise HTTPException(
|
|
27
|
+
status_code=413,
|
|
28
|
+
detail=f"Image exceeds {constants.MAX_IMAGE_BYTES} bytes.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.get("/", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
33
|
+
async def get_branding(service: BrandingServiceDep) -> BrandingOut:
|
|
34
|
+
return service.current()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@router.put("/", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
38
|
+
async def update_branding(data: BrandingUpdate, service: BrandingServiceDep) -> BrandingOut:
|
|
39
|
+
changes = {k: v for k, v in data.model_dump(exclude_unset=True).items() if v is not None}
|
|
40
|
+
if not changes:
|
|
41
|
+
return service.current()
|
|
42
|
+
return await service.apply(changes)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.post("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
46
|
+
async def upload_logo(
|
|
47
|
+
service: BrandingServiceDep,
|
|
48
|
+
file: UploadFile,
|
|
49
|
+
storage: FileStorageService = Depends(get_file_storage_service),
|
|
50
|
+
) -> BrandingOut:
|
|
51
|
+
_validate_image(file)
|
|
52
|
+
stored = await storage.upload(file)
|
|
53
|
+
return await service.set_logo(str(stored.id))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@router.post("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
57
|
+
async def upload_favicon(
|
|
58
|
+
service: BrandingServiceDep,
|
|
59
|
+
file: UploadFile,
|
|
60
|
+
storage: FileStorageService = Depends(get_file_storage_service),
|
|
61
|
+
) -> BrandingOut:
|
|
62
|
+
_validate_image(file)
|
|
63
|
+
stored = await storage.upload(file)
|
|
64
|
+
return await service.set_favicon(str(stored.id))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.delete("/logo", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
68
|
+
async def clear_logo(service: BrandingServiceDep) -> BrandingOut:
|
|
69
|
+
return await service.clear_logo()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.delete("/favicon", response_model=BrandingOut, dependencies=[_MANAGE])
|
|
73
|
+
async def clear_favicon(service: BrandingServiceDep) -> BrandingOut:
|
|
74
|
+
return await service.clear_favicon()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Inertia view endpoints for Branding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends
|
|
6
|
+
from inertia import InertiaResponse
|
|
7
|
+
from simple_module_hosting.inertia_deps import InertiaDep
|
|
8
|
+
from simple_module_hosting.permissions import RequiresPermission
|
|
9
|
+
|
|
10
|
+
from branding import constants
|
|
11
|
+
|
|
12
|
+
router = APIRouter()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@router.get(
|
|
16
|
+
"/",
|
|
17
|
+
response_model=None,
|
|
18
|
+
dependencies=[Depends(RequiresPermission(constants.PERM_VIEW))],
|
|
19
|
+
)
|
|
20
|
+
async def manage(inertia: InertiaDep) -> InertiaResponse:
|
|
21
|
+
# Current branding is delivered through the shared ``branding`` prop
|
|
22
|
+
# (the branding shared-props provider), so no page props are needed.
|
|
23
|
+
# The page name is inlined as a literal (rather than constants._PAGE_MANAGE)
|
|
24
|
+
# so the SM003/SM004 diagnostics — which do static AST analysis and can't
|
|
25
|
+
# resolve attribute access — pair this call with pages/Manage.tsx. A unit
|
|
26
|
+
# test asserts the literal matches constants._PAGE_MANAGE.
|
|
27
|
+
return await inertia.render(
|
|
28
|
+
"Branding/Manage",
|
|
29
|
+
)
|
branding/locales/en.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manage": {
|
|
3
|
+
"title": "Branding",
|
|
4
|
+
"description": "Customise your application's name, logo, favicon and primary colour.",
|
|
5
|
+
"app_name_label": "Application name",
|
|
6
|
+
"app_name_help": "Shown in the sidebar, the browser tab and on sign-in.",
|
|
7
|
+
"primary_color_label": "Primary colour",
|
|
8
|
+
"primary_color_help": "Accent colour used across buttons and highlights. Leave blank for the default.",
|
|
9
|
+
"logo_label": "Logo",
|
|
10
|
+
"logo_help": "Square PNG or SVG works best. Replaces the default badge in the sidebar.",
|
|
11
|
+
"favicon_label": "Favicon",
|
|
12
|
+
"favicon_help": "Small icon shown in the browser tab. PNG, SVG or ICO.",
|
|
13
|
+
"upload_button": "Upload",
|
|
14
|
+
"replace_button": "Replace",
|
|
15
|
+
"remove_button": "Remove",
|
|
16
|
+
"save_button": "Save changes",
|
|
17
|
+
"saving": "Saving…",
|
|
18
|
+
"preview_title": "Preview",
|
|
19
|
+
"saved_toast": "Branding updated",
|
|
20
|
+
"error_toast": "Could not update branding",
|
|
21
|
+
"upload_error_toast": "Could not upload image"
|
|
22
|
+
}
|
|
23
|
+
}
|
branding/module.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Branding module definition.
|
|
2
|
+
|
|
3
|
+
Lets an administrator customise the app name, logo, favicon and primary colour.
|
|
4
|
+
Values persist in the shared settings store (no branding table) and reach every
|
|
5
|
+
page through a registered Inertia shared-props provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, FastAPI
|
|
14
|
+
from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
|
|
15
|
+
from simple_module_core.module import ModuleBase, ModuleMeta
|
|
16
|
+
from simple_module_core.permissions import PermissionRegistry
|
|
17
|
+
|
|
18
|
+
from branding import constants
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BrandingModule(ModuleBase):
|
|
22
|
+
meta = ModuleMeta(
|
|
23
|
+
name="Branding",
|
|
24
|
+
route_prefix="/api/branding",
|
|
25
|
+
view_prefix="/branding",
|
|
26
|
+
depends_on=[constants._MODULE_SETTINGS, constants._MODULE_FILE_STORAGE],
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def register_settings(self, app: FastAPI) -> None:
|
|
30
|
+
from settings.registration import register_module_settings
|
|
31
|
+
|
|
32
|
+
from branding.services import BrandingServices
|
|
33
|
+
from branding.settings import BrandingSettings
|
|
34
|
+
|
|
35
|
+
register_module_settings(
|
|
36
|
+
app,
|
|
37
|
+
constants.PACKAGE,
|
|
38
|
+
BrandingSettings,
|
|
39
|
+
lambda s: BrandingServices(settings=s),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def register_permissions(self, registry: PermissionRegistry) -> None:
|
|
43
|
+
registry.add_group(
|
|
44
|
+
"Branding",
|
|
45
|
+
[constants.PERM_VIEW, constants.PERM_MANAGE],
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def register_menu_items(self, registry: MenuRegistry) -> None:
|
|
49
|
+
registry.add(
|
|
50
|
+
MenuItem(
|
|
51
|
+
label="Branding",
|
|
52
|
+
url="/branding",
|
|
53
|
+
icon="palette",
|
|
54
|
+
order=115,
|
|
55
|
+
section=MenuSection.SIDEBAR,
|
|
56
|
+
group="Administration",
|
|
57
|
+
roles=["admin"],
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
|
|
62
|
+
from branding.endpoints.api import router as api
|
|
63
|
+
from branding.endpoints.views import router as views
|
|
64
|
+
|
|
65
|
+
api_router.include_router(api)
|
|
66
|
+
view_router.include_router(views)
|
|
67
|
+
|
|
68
|
+
async def on_startup(self, app: FastAPI) -> None:
|
|
69
|
+
from simple_module_hosting.shared_props import register_inertia_shared_provider
|
|
70
|
+
|
|
71
|
+
from branding.shared_props import branding_shared_props
|
|
72
|
+
|
|
73
|
+
register_inertia_shared_provider(app, branding_shared_props)
|
|
74
|
+
|
|
75
|
+
def locale_dirs(self) -> dict[str, Path]:
|
|
76
|
+
base = Path(str(importlib.resources.files(__package__) / "locales"))
|
|
77
|
+
return {constants.PACKAGE: base}
|
branding/package.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@simple-module-py/branding",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Frontend assets for the Branding module",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^19.0.0",
|
|
8
|
+
"react-dom": "^19.0.0",
|
|
9
|
+
"@inertiajs/react": "^2.0.0",
|
|
10
|
+
"@simple-module-py/ui": "*"
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@simple-module-py/tsconfig": "*"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {}
|
|
16
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { Head, router, usePage } from '@inertiajs/react';
|
|
2
|
+
import { keys, useT } from '@simple-module-py/i18n';
|
|
3
|
+
import { PageShell } from '@simple-module-py/ui/components/PageShell';
|
|
4
|
+
import { Button } from '@simple-module-py/ui/components/ui/button';
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle,
|
|
11
|
+
} from '@simple-module-py/ui/components/ui/card';
|
|
12
|
+
import { Input } from '@simple-module-py/ui/components/ui/input';
|
|
13
|
+
import { Label } from '@simple-module-py/ui/components/ui/label';
|
|
14
|
+
import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
|
|
15
|
+
import type { SharedProps } from '@simple-module-py/ui/types';
|
|
16
|
+
import { type ChangeEvent, useRef, useState } from 'react';
|
|
17
|
+
import { toast } from 'sonner';
|
|
18
|
+
|
|
19
|
+
const DEFAULT_SWATCH = '#10b981';
|
|
20
|
+
type ImageKind = 'logo' | 'favicon';
|
|
21
|
+
|
|
22
|
+
async function readError(res: Response): Promise<string> {
|
|
23
|
+
try {
|
|
24
|
+
const body = await res.json();
|
|
25
|
+
return typeof body?.detail === 'string' ? body.detail : res.statusText;
|
|
26
|
+
} catch {
|
|
27
|
+
return res.statusText;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ImageField({
|
|
32
|
+
label,
|
|
33
|
+
help,
|
|
34
|
+
url,
|
|
35
|
+
onUpload,
|
|
36
|
+
onRemove,
|
|
37
|
+
disabled,
|
|
38
|
+
}: {
|
|
39
|
+
label: string;
|
|
40
|
+
help: string;
|
|
41
|
+
url: string | null;
|
|
42
|
+
onUpload: (file: File) => void;
|
|
43
|
+
onRemove: () => void;
|
|
44
|
+
disabled: boolean;
|
|
45
|
+
}) {
|
|
46
|
+
const { t } = useT();
|
|
47
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div className="space-y-2">
|
|
51
|
+
<Label>{label}</Label>
|
|
52
|
+
<div className="flex items-center gap-4">
|
|
53
|
+
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-lg border bg-muted">
|
|
54
|
+
{url ? (
|
|
55
|
+
<img src={url} alt={label} className="h-full w-full object-contain" />
|
|
56
|
+
) : (
|
|
57
|
+
<span className="text-xs text-muted-foreground">—</span>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="flex gap-2">
|
|
61
|
+
<input
|
|
62
|
+
ref={inputRef}
|
|
63
|
+
type="file"
|
|
64
|
+
accept="image/*"
|
|
65
|
+
className="hidden"
|
|
66
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
67
|
+
const file = e.target.files?.[0];
|
|
68
|
+
if (file) onUpload(file);
|
|
69
|
+
e.target.value = '';
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
<Button
|
|
73
|
+
type="button"
|
|
74
|
+
variant="outline"
|
|
75
|
+
size="sm"
|
|
76
|
+
disabled={disabled}
|
|
77
|
+
onClick={() => inputRef.current?.click()}
|
|
78
|
+
>
|
|
79
|
+
{url ? t(keys.branding.manage.replace_button) : t(keys.branding.manage.upload_button)}
|
|
80
|
+
</Button>
|
|
81
|
+
{url && (
|
|
82
|
+
<Button type="button" variant="ghost" size="sm" disabled={disabled} onClick={onRemove}>
|
|
83
|
+
{t(keys.branding.manage.remove_button)}
|
|
84
|
+
</Button>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<p className="text-xs text-muted-foreground">{help}</p>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function Manage() {
|
|
94
|
+
const { t } = useT();
|
|
95
|
+
const { auth, branding } = usePage<{ props: SharedProps }>().props as unknown as SharedProps;
|
|
96
|
+
const canManage = auth?.permissions?.includes('branding.manage');
|
|
97
|
+
|
|
98
|
+
const [appName, setAppName] = useState(branding?.appName ?? '');
|
|
99
|
+
const [color, setColor] = useState(branding?.primaryColor ?? '');
|
|
100
|
+
const [busy, setBusy] = useState(false);
|
|
101
|
+
|
|
102
|
+
async function run(work: () => Promise<Response>, errorMsg: string) {
|
|
103
|
+
setBusy(true);
|
|
104
|
+
try {
|
|
105
|
+
const res = await work();
|
|
106
|
+
if (!res.ok) throw new Error(await readError(res));
|
|
107
|
+
toast.success(t(keys.branding.manage.saved_toast));
|
|
108
|
+
router.reload();
|
|
109
|
+
} catch (err) {
|
|
110
|
+
toast.error(`${errorMsg}: ${(err as Error).message}`);
|
|
111
|
+
} finally {
|
|
112
|
+
setBusy(false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const saveText = () =>
|
|
117
|
+
run(
|
|
118
|
+
() =>
|
|
119
|
+
fetch('/api/branding/', {
|
|
120
|
+
method: 'PUT',
|
|
121
|
+
headers: { 'Content-Type': 'application/json' },
|
|
122
|
+
body: JSON.stringify({ app_name: appName, primary_color: color }),
|
|
123
|
+
}),
|
|
124
|
+
t(keys.branding.manage.error_toast),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const uploadImage = (kind: ImageKind, file: File) => {
|
|
128
|
+
const form = new FormData();
|
|
129
|
+
form.append('file', file);
|
|
130
|
+
return run(
|
|
131
|
+
() => fetch(`/api/branding/${kind}`, { method: 'POST', body: form }),
|
|
132
|
+
t(keys.branding.manage.upload_error_toast),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const removeImage = (kind: ImageKind) =>
|
|
137
|
+
run(
|
|
138
|
+
() => fetch(`/api/branding/${kind}`, { method: 'DELETE' }),
|
|
139
|
+
t(keys.branding.manage.error_toast),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<>
|
|
144
|
+
<Head title={t(keys.branding.manage.title)} />
|
|
145
|
+
<PageShell
|
|
146
|
+
title={t(keys.branding.manage.title)}
|
|
147
|
+
description={t(keys.branding.manage.description)}
|
|
148
|
+
>
|
|
149
|
+
<div className="grid gap-6 lg:grid-cols-3">
|
|
150
|
+
<Card className="lg:col-span-2">
|
|
151
|
+
<CardHeader>
|
|
152
|
+
<CardTitle>{t(keys.branding.manage.title)}</CardTitle>
|
|
153
|
+
<CardDescription>{t(keys.branding.manage.description)}</CardDescription>
|
|
154
|
+
</CardHeader>
|
|
155
|
+
<CardContent className="space-y-6">
|
|
156
|
+
<div className="space-y-2">
|
|
157
|
+
<Label htmlFor="app_name">{t(keys.branding.manage.app_name_label)}</Label>
|
|
158
|
+
<Input
|
|
159
|
+
id="app_name"
|
|
160
|
+
value={appName}
|
|
161
|
+
maxLength={60}
|
|
162
|
+
disabled={!canManage || busy}
|
|
163
|
+
onChange={(e) => setAppName(e.target.value)}
|
|
164
|
+
/>
|
|
165
|
+
<p className="text-xs text-muted-foreground">
|
|
166
|
+
{t(keys.branding.manage.app_name_help)}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<div className="space-y-2">
|
|
171
|
+
<Label htmlFor="primary_color">{t(keys.branding.manage.primary_color_label)}</Label>
|
|
172
|
+
<div className="flex items-center gap-3">
|
|
173
|
+
<input
|
|
174
|
+
type="color"
|
|
175
|
+
aria-label={t(keys.branding.manage.primary_color_label)}
|
|
176
|
+
value={color || DEFAULT_SWATCH}
|
|
177
|
+
disabled={!canManage || busy}
|
|
178
|
+
onChange={(e) => setColor(e.target.value)}
|
|
179
|
+
className="h-9 w-12 cursor-pointer rounded border bg-transparent"
|
|
180
|
+
/>
|
|
181
|
+
<Input
|
|
182
|
+
id="primary_color"
|
|
183
|
+
value={color}
|
|
184
|
+
placeholder={DEFAULT_SWATCH}
|
|
185
|
+
disabled={!canManage || busy}
|
|
186
|
+
onChange={(e) => setColor(e.target.value)}
|
|
187
|
+
className="max-w-40 font-mono"
|
|
188
|
+
/>
|
|
189
|
+
{color && (
|
|
190
|
+
<Button
|
|
191
|
+
type="button"
|
|
192
|
+
variant="ghost"
|
|
193
|
+
size="sm"
|
|
194
|
+
disabled={busy}
|
|
195
|
+
onClick={() => setColor('')}
|
|
196
|
+
>
|
|
197
|
+
{t(keys.branding.manage.remove_button)}
|
|
198
|
+
</Button>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
<p className="text-xs text-muted-foreground">
|
|
202
|
+
{t(keys.branding.manage.primary_color_help)}
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<ImageField
|
|
207
|
+
label={t(keys.branding.manage.logo_label)}
|
|
208
|
+
help={t(keys.branding.manage.logo_help)}
|
|
209
|
+
url={branding?.logoUrl ?? null}
|
|
210
|
+
onUpload={(file) => uploadImage('logo', file)}
|
|
211
|
+
onRemove={() => removeImage('logo')}
|
|
212
|
+
disabled={!canManage || busy}
|
|
213
|
+
/>
|
|
214
|
+
<ImageField
|
|
215
|
+
label={t(keys.branding.manage.favicon_label)}
|
|
216
|
+
help={t(keys.branding.manage.favicon_help)}
|
|
217
|
+
url={branding?.faviconUrl ?? null}
|
|
218
|
+
onUpload={(file) => uploadImage('favicon', file)}
|
|
219
|
+
onRemove={() => removeImage('favicon')}
|
|
220
|
+
disabled={!canManage || busy}
|
|
221
|
+
/>
|
|
222
|
+
|
|
223
|
+
<Button type="button" disabled={!canManage || busy} onClick={saveText}>
|
|
224
|
+
{busy ? t(keys.branding.manage.saving) : t(keys.branding.manage.save_button)}
|
|
225
|
+
</Button>
|
|
226
|
+
</CardContent>
|
|
227
|
+
</Card>
|
|
228
|
+
|
|
229
|
+
<Card>
|
|
230
|
+
<CardHeader>
|
|
231
|
+
<CardTitle>{t(keys.branding.manage.preview_title)}</CardTitle>
|
|
232
|
+
</CardHeader>
|
|
233
|
+
<CardContent>
|
|
234
|
+
<div className="flex items-center gap-3 rounded-lg border p-4">
|
|
235
|
+
<div
|
|
236
|
+
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg text-white"
|
|
237
|
+
style={{ backgroundColor: color || DEFAULT_SWATCH }}
|
|
238
|
+
>
|
|
239
|
+
{branding?.logoUrl ? (
|
|
240
|
+
<img
|
|
241
|
+
src={branding.logoUrl}
|
|
242
|
+
alt={appName}
|
|
243
|
+
className="h-full w-full object-contain"
|
|
244
|
+
/>
|
|
245
|
+
) : (
|
|
246
|
+
<span className="font-bold">{(appName.trim()[0] ?? 'S').toUpperCase()}</span>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
<span className="font-semibold">{appName || 'SimpleModule'}</span>
|
|
250
|
+
</div>
|
|
251
|
+
</CardContent>
|
|
252
|
+
</Card>
|
|
253
|
+
</div>
|
|
254
|
+
</PageShell>
|
|
255
|
+
</>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
Manage.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
|
|
260
|
+
|
|
261
|
+
export default Manage;
|
branding/py.typed
ADDED
|
File without changes
|
branding/service.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Branding service — reads current branding and applies admin changes.
|
|
2
|
+
|
|
3
|
+
There is no branding DB table: values live in the shared settings store. Writes
|
|
4
|
+
go through ``settings.reload.apply_changes_and_reload`` which validates against
|
|
5
|
+
``BrandingSettings``, persists (SYSTEM scope), hot-swaps ``app.state.branding``
|
|
6
|
+
and publishes ``SettingsReloaded``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from branding.constants import PACKAGE
|
|
14
|
+
from branding.contracts.schemas import BrandingOut
|
|
15
|
+
from branding.shared_props import file_url
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrandingService:
|
|
23
|
+
"""Read/update the application's branding."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, app: FastAPI, db: AsyncSession) -> None:
|
|
26
|
+
self.app = app
|
|
27
|
+
self.db = db
|
|
28
|
+
|
|
29
|
+
def current(self) -> BrandingOut:
|
|
30
|
+
settings = self.app.state.branding.settings
|
|
31
|
+
return BrandingOut(
|
|
32
|
+
app_name=settings.app_name,
|
|
33
|
+
primary_color=settings.primary_color,
|
|
34
|
+
logo_url=file_url(settings.logo_file_id),
|
|
35
|
+
favicon_url=file_url(settings.favicon_file_id),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def apply(self, changes: dict[str, Any]) -> BrandingOut:
|
|
39
|
+
"""Persist and hot-swap the given field changes, then return current."""
|
|
40
|
+
# Plugin→plugin imports (settings is a declared dependency); kept local
|
|
41
|
+
# so import order during discovery stays tolerant.
|
|
42
|
+
from settings.reload import apply_changes_and_reload
|
|
43
|
+
from settings.service import SettingService
|
|
44
|
+
from settings.store import SettingsStore
|
|
45
|
+
|
|
46
|
+
store = SettingsStore(SettingService(self.db))
|
|
47
|
+
bus = self.app.state.sm.event_bus
|
|
48
|
+
await apply_changes_and_reload(self.app, bus, store, package=PACKAGE, changes=changes)
|
|
49
|
+
return self.current()
|
|
50
|
+
|
|
51
|
+
async def set_logo(self, file_id: str) -> BrandingOut:
|
|
52
|
+
return await self.apply({"logo_file_id": file_id})
|
|
53
|
+
|
|
54
|
+
async def set_favicon(self, file_id: str) -> BrandingOut:
|
|
55
|
+
return await self.apply({"favicon_file_id": file_id})
|
|
56
|
+
|
|
57
|
+
async def clear_logo(self) -> BrandingOut:
|
|
58
|
+
return await self.apply({"logo_file_id": ""})
|
|
59
|
+
|
|
60
|
+
async def clear_favicon(self) -> BrandingOut:
|
|
61
|
+
return await self.apply({"favicon_file_id": ""})
|
branding/services.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Module-scoped state container.
|
|
2
|
+
|
|
3
|
+
Stored as ``app.state.branding`` by
|
|
4
|
+
:meth:`BrandingModule.register_settings` (via ``register_module_settings``).
|
|
5
|
+
``settings`` is hot-swapped by ``settings.reload.apply_changes_and_reload`` when
|
|
6
|
+
an admin saves changes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from branding.settings import BrandingSettings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BrandingServices:
|
|
18
|
+
"""Branding module singletons."""
|
|
19
|
+
|
|
20
|
+
settings: BrandingSettings
|
branding/settings.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Branding module settings — DB-backed via ``register_module_settings``.
|
|
2
|
+
|
|
3
|
+
The four values an administrator can customise (app name, logo, favicon,
|
|
4
|
+
primary colour) are stored in the shared settings store at SYSTEM scope,
|
|
5
|
+
hydrated into ``app.state.branding.settings`` at boot, and hot-swapped on save
|
|
6
|
+
through ``settings.reload.apply_changes_and_reload``.
|
|
7
|
+
|
|
8
|
+
Logo/favicon are stored as ``file_storage`` UUIDs (empty string = unset); the
|
|
9
|
+
frontend derives a download URL from the id.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pydantic import field_validator
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
from branding.constants import HEX_COLOR_RE, clean_app_name
|
|
18
|
+
|
|
19
|
+
DEFAULT_APP_NAME = "SimpleModule"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BrandingSettings(BaseSettings):
|
|
23
|
+
"""Customisable application identity."""
|
|
24
|
+
|
|
25
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
26
|
+
|
|
27
|
+
app_name: str = DEFAULT_APP_NAME
|
|
28
|
+
primary_color: str = "" # "" = use the theme default; otherwise "#rrggbb"
|
|
29
|
+
logo_file_id: str = "" # file_storage UUID, "" = no custom logo
|
|
30
|
+
favicon_file_id: str = "" # file_storage UUID, "" = no custom favicon
|
|
31
|
+
|
|
32
|
+
@field_validator("app_name")
|
|
33
|
+
@classmethod
|
|
34
|
+
def _non_empty_name(cls, value: str) -> str:
|
|
35
|
+
return clean_app_name(value)
|
|
36
|
+
|
|
37
|
+
@field_validator("primary_color")
|
|
38
|
+
@classmethod
|
|
39
|
+
def _valid_hex(cls, value: str) -> str:
|
|
40
|
+
if value and not HEX_COLOR_RE.match(value):
|
|
41
|
+
raise ValueError("primary_color must be a #rrggbb hex string or empty")
|
|
42
|
+
return value.lower()
|
branding/shared_props.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Branding shared-props provider.
|
|
2
|
+
|
|
3
|
+
Registered on ``app.state.inertia_shared_providers`` so every Inertia page
|
|
4
|
+
(authenticated *and* guest) receives a ``branding`` block in its shared props.
|
|
5
|
+
The frontend uses it for the app name, logo, favicon and primary colour.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from branding.constants import FILE_DOWNLOAD_URL
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from starlette.requests import Request
|
|
16
|
+
|
|
17
|
+
from branding.settings import BrandingSettings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def file_url(file_id: str) -> str | None:
|
|
21
|
+
"""Build the file_storage download URL for a stored file id (or None)."""
|
|
22
|
+
return FILE_DOWNLOAD_URL.format(file_id=file_id) if file_id else None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def branding_payload(settings: BrandingSettings) -> dict:
|
|
26
|
+
"""The camelCase branding block shared with the frontend."""
|
|
27
|
+
return {
|
|
28
|
+
"appName": settings.app_name,
|
|
29
|
+
"primaryColor": settings.primary_color or None,
|
|
30
|
+
"logoUrl": file_url(settings.logo_file_id),
|
|
31
|
+
"faviconUrl": file_url(settings.favicon_file_id),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def branding_shared_props(request: Request) -> dict:
|
|
36
|
+
"""Provider: emit ``{"branding": {...}}`` from the live module settings.
|
|
37
|
+
|
|
38
|
+
Defensive — returns ``{}`` if the branding state isn't mounted yet, so a
|
|
39
|
+
half-booted app never errors a page render.
|
|
40
|
+
"""
|
|
41
|
+
services = getattr(request.app.state, "branding", None)
|
|
42
|
+
if services is None:
|
|
43
|
+
return {}
|
|
44
|
+
return {"branding": branding_payload(services.settings)}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simple_module_branding
|
|
3
|
+
Version: 0.0.24
|
|
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.24
|
|
11
|
+
Requires-Dist: simple-module-db==0.0.24
|
|
12
|
+
Requires-Dist: simple-module-file-storage==0.0.24
|
|
13
|
+
Requires-Dist: simple-module-hosting==0.0.24
|
|
14
|
+
Requires-Dist: simple-module-settings==0.0.24
|
|
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**, **favicon** and
|
|
22
|
+
**primary brand colour** from the admin UI (`/branding`), and those values are
|
|
23
|
+
applied everywhere the framework would otherwise show the default identity —
|
|
24
|
+
the sidebar/header logo and name, the browser tab title, the favicon, and the
|
|
25
|
+
primary accent colour.
|
|
26
|
+
|
|
27
|
+
## Screenshots
|
|
28
|
+
|
|
29
|
+
The admin page at `/branding`, and the same app rebranded as "Acme Analytics"
|
|
30
|
+
(custom logo + name + primary colour) across the sidebar and dashboard:
|
|
31
|
+
|
|
32
|
+
| Admin page | Branding applied | Across the app |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
|  |  |  |
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
The module ships with the default app. To add it to a custom host, declare it
|
|
39
|
+
as a dependency and let entry-point discovery pick it up:
|
|
40
|
+
|
|
41
|
+
```toml
|
|
42
|
+
# host/pyproject.toml
|
|
43
|
+
dependencies = ["simple_module_branding"]
|
|
44
|
+
|
|
45
|
+
[tool.uv.sources]
|
|
46
|
+
simple_module_branding = { workspace = true }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then `uv sync --all-packages`. It requires the `Settings` and `FileStorage`
|
|
50
|
+
modules to be installed too.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
1. Sign in as an admin and open **Branding** in the sidebar (or visit
|
|
55
|
+
`/branding`).
|
|
56
|
+
2. Set the application name, pick a primary colour, and upload a logo and/or
|
|
57
|
+
favicon. Changes apply immediately across the app.
|
|
58
|
+
|
|
59
|
+
Programmatically, the current branding is available on every page through the
|
|
60
|
+
`branding` Inertia shared prop (`appName`, `primaryColor`, `logoUrl`,
|
|
61
|
+
`faviconUrl`).
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
- **Storage.** The four values are persisted via the `settings` module's store
|
|
66
|
+
(SYSTEM scope) — there is no branding database table. They hydrate into
|
|
67
|
+
`app.state.branding.settings` at boot and hot-swap on save.
|
|
68
|
+
- **Images.** Logo and favicon uploads are stored through the `file_storage`
|
|
69
|
+
module (referenced by UUID) and served from its download endpoint.
|
|
70
|
+
- **Delivery.** A registered Inertia shared-props provider injects a `branding`
|
|
71
|
+
block into every page's shared props (authenticated *and* guest), which the
|
|
72
|
+
frontend reads for the name, logo, favicon and colour.
|
|
73
|
+
|
|
74
|
+
## Permissions
|
|
75
|
+
|
|
76
|
+
- `branding.view` — view the branding admin page.
|
|
77
|
+
- `branding.manage` — change branding (name, colour, logo, favicon).
|
|
78
|
+
|
|
79
|
+
## Dependencies
|
|
80
|
+
|
|
81
|
+
Depends on the `Settings` and `FileStorage` modules.
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- Branding is currently SYSTEM-scoped (one identity per deployment). The
|
|
86
|
+
settings store already supports tenant/user scope, leaving room for
|
|
87
|
+
per-tenant branding later.
|
|
88
|
+
- The primary colour overrides the `--primary` / `--sidebar-primary` CSS
|
|
89
|
+
variables from a single hex; the full OKLCH colour scale is not regenerated.
|
|
90
|
+
|
|
91
|
+
License: MIT
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
branding/__init__.py,sha256=Oa-2vuIajUpsnnyueXSEHc9vcvd6_2nSMdlO7r53lRs,23
|
|
2
|
+
branding/constants.py,sha256=-sTqm2wlKsxyZwjhIVqzWlnGeU06FAb2ExbC6iFZMbo,2358
|
|
3
|
+
branding/deps.py,sha256=fOSykBZE9Oo5CxMUiJ-3XnX8znkc8Vkzq0H55W4rU9w,534
|
|
4
|
+
branding/module.py,sha256=RPI7t7jYwVf6e7MMldPhwZYxd3iZa0SFvpipEhCEU9Y,2565
|
|
5
|
+
branding/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
branding/service.py,sha256=fCmCTrdmBtf8AOHBZi0itLuymBMKm3U8HNy5ac5Lzf0,2313
|
|
7
|
+
branding/services.py,sha256=9vIoBm7XXajTKrKSbtzLFZUQd8GFSjbCy2a40SeiHow,483
|
|
8
|
+
branding/settings.py,sha256=IFl0nQf-_otR1xTlSn6osZ-xGtfpJmrsKW4km0RPSSs,1520
|
|
9
|
+
branding/shared_props.py,sha256=ams6xwtLuVHOFTTp479B0cZlsnPDxQg2z6pvLauA7qU,1477
|
|
10
|
+
branding/contracts/__init__.py,sha256=t-odwPDL0_VvnvZeTE_-kf9r_DlkJ8ChLCgb0hgXaDA,189
|
|
11
|
+
branding/contracts/schemas.py,sha256=du41MIj9PKRC7ik5gSH4AvALW1CcJBXIpDO4eLQlXJs,1490
|
|
12
|
+
branding/endpoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
branding/endpoints/api.py,sha256=HfWR3ntnx7T4s98QDUOr1K5j8l9FzuKBd-WuolP6tlk,2661
|
|
14
|
+
branding/endpoints/views.py,sha256=TFYFKBB48DhQ5uajOV4WZm6-kitACq8_jbpAn44stc0,1026
|
|
15
|
+
branding/locales/en.json,sha256=YefFECIyrc9ws9ug2Dvpj5CK1xoa9pjYrDA9M5RoPv4,966
|
|
16
|
+
branding/pages/Manage.tsx,sha256=LWUnGaz7YAfos9O1k2VlObQKplmLQF-4ZUhruZ5uaJ8,9156
|
|
17
|
+
branding/package.json,sha256=JL1APdonxmY0KeDEQToAiSVsLP3G1Ez3L1MI9tpapps,379
|
|
18
|
+
simple_module_branding-0.0.24.dist-info/METADATA,sha256=x4yAeJM6mf0WpQsbmi88sTnmxFe_mYhBvr77JN1F1A0,3572
|
|
19
|
+
simple_module_branding-0.0.24.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
simple_module_branding-0.0.24.dist-info/entry_points.txt,sha256=G-jt7Ul0Kc8Q3xE72Dts_LmwGG-s8MDQe3HEWoPysfk,58
|
|
21
|
+
simple_module_branding-0.0.24.dist-info/RECORD,,
|