diff --git a/.dockerignore b/.dockerignore
index f7d9f4b..bab648a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,5 @@
data
uploads
.venv
+client/node_modules
+client/.vite
diff --git a/.editorconfig b/.editorconfig
index 1716961..8777645 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,5 +11,5 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
-[*.{yml,yaml}]
+[*.{yml,yaml,ts,tsx,js,jsx,json}]
indent_size = 2
diff --git a/Dockerfile b/Dockerfile
index 06e15c4..0aa3398 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,14 @@
+FROM node:18-alpine AS frontend-build
+
+WORKDIR /app
+
+COPY client/ .
+
+RUN npm i -g corepack@latest \
+ && corepack enable \
+ && pnpm install --frozen-lockfile \
+ && pnpm run build
+
FROM python:alpine
WORKDIR /opt
@@ -9,8 +20,9 @@ RUN apk add --update --no-cache ffmpeg \
&& uv sync --frozen
COPY . /opt
+COPY --from=frontend-build /app/dist /opt/dist
ENV PG_DIRECTORY=/work
ENV PG_UPLOADS_DIRECTORY=/uploads
-CMD ["uv", "run", "fastapi", "run", "/opt/src/main.py", "--port", "8000"]
+CMD ["uv", "run", "uvicorn", "--app-dir", "/opt/src", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/README.md b/README.md
index d8c8f43..266d968 100644
--- a/README.md
+++ b/README.md
@@ -2,58 +2,51 @@
This is a really simple project allowing for quick creation of podcasts. The server provides a simple admin panel to manage podcasts, and the ability to upload episodes. Any uploaded episodes have their audio level normalised, and are re-encoded to AAC which tends to use less disk space than original formats.
-## Deployment
-
-This is designed to sit behind a reverse proxy set up with forward authentication to protect the admin panel. Although, basic authentication should work too.
-
-```docker
-services:
- server:
- image: git.jakew.me/jakew/podcast-generator:latest
- restart: unless-stopped
- environment:
- - PG_FEEDS=["mypodcast"]
- volumes:
- - data:/work
-
-volumes:
- data:
-```
-
-Example reverse proxy configuration for Caddy:
-
-```caddy
-podcast.example.org {
- # rewrite the base admin path to have a trailing slash
- @admin path /admin /admin/*
-
- # apply forward authentication to just admin routes
- # this is only an example, refer to your auth provider documentation
- forward_auth @admin https://auth.example.org {
- copy_headers X-Auth-User ...
- }
-
- # proxy requests to the app
- reverse_proxy podcast-server:8000
-}
-```
-
### Environment Variables
| Name | Default | Description |
| ---- | ------- | ----------- |
| `PG_DIRECTORY` | `./data` (`/data` for Docker) | Where any files are stored. This includes episodes, images and application data. |
| `PG_UPLOADS_DIRECTORY` | `./uploads` (`/uploads` for Docker) | Where any currently uploading files are stored. This directory does not need persistence in Docker. |
-| `PG_FEEDS` | `["default"]` | A JSON array of the podcast names to be initially created. |
+| `PG_OIDC_AUTHORIZE_URL` _(required)_ | | The OpenID Connect authorize endpoint from the authentication provider. |
+| `PG_OIDC_TOKEN_URL` _(required)_ | | The OpenID Connect token endpoint from the authentication provider. |
+| `PG_OIDC_JWKS_URL` _(required)_ | | The OpenID Connect JWKS endpoint from the authentication provider. |
+| `PG_OIDC_AUTHORITY` _(required)_ | | The OpenID Connect issuer URL from the authentication provider. |
+| `PG_OIDC_PERMITTED_JWT_AUDIENCES` _(required)_ | | A list of valid audiences from the authentication provider. |
+| `PG_OIDC_CLIENT_ID` _(required)_ | | The OpenID Connect client ID from the authentication provider. |
+| `PG_OIDC_SUB_JWT_ATTRIBUTE` | `sub` | The JWT token attribute that contains the user's ID. |
+| `PG_OIDC_NAME_JWT_ATTRIBUTE` | `name` | The JWT token attribute that contains the user's name or username. |
+| `PG_OIDC_SCOPES` | `["openid", "email", "profile", "offline_access"]` | OpenID Connect scopes. |
## Development
-This project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed.
+### Backend
+
+This backend of the project is made using Python and FastAPI. To get started, ensure you have [Python](https://www.python.org/) and the [uv](https://docs.astral.sh/uv/) package manager installed.
```bash
# install dependencies
uv sync
+# migrate the database
+uv run alembic upgrade head
+
# run server in development mode
uv run fastapi dev main.py
```
+
+### Frontend
+
+The frontend of the project is made using Solid.js. To get started, ensure you have [Node.js]() installed, and corepack enabled (with `corepack enable`).
+
+```bash
+cd frontend
+
+# install dependencies
+pnpm install
+
+# run server in development mode
+pnpm run dev
+```
+
+After changing the backend, run `pnpm run generate-client` to generate the API client for the frontend from the server's OpenAPI schema.
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 0000000..5ab5ee0
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,43 @@
+version: '3'
+
+tasks:
+ setup:
+ deps: [backend:setup, frontend:setup]
+
+ backend:setup:
+ internal: true
+ cmds:
+ - uv sync
+
+ backend:migrate:
+ cmds:
+ - uv run alembic upgrade head
+
+ backend:dev:
+ deps: [backend:migrate]
+ cmds:
+ - uv run fastapi dev src/main.py
+
+ frontend:setup:
+ internal: true
+ dir: ./client
+ cmds:
+ - pnpm install
+
+ frontend:generate:
+ internal: true
+ dir: ./client
+ cmds:
+ - pnpm generate-client
+
+ frontend:dev:
+ dir: ./client
+ deps: [frontend:generate]
+ cmds:
+ - pnpm run dev
+
+ frontend:build:
+ dir: ./client
+ deps: [frontend:generate]
+ cmds:
+ - pnpm run build
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 0000000..b276e79
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,119 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+# Use forward slashes (/) also on windows to provide an os agnostic path
+script_location = migrations
+
+# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
+# Uncomment the line below if you want the files to be prepended with date and time
+# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
+# for all available tokens
+# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
+
+# sys.path path, will be prepended to sys.path if present.
+# defaults to the current working directory.
+prepend_sys_path = ./src
+
+# timezone to use when rendering the date within the migration file
+# as well as the filename.
+# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
+# Any required deps can installed by adding `alembic[tz]` to the pip requirements
+# string value is passed to ZoneInfo()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the "slug" field
+# truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; This defaults
+# to migrations/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path.
+# The path separator used here should be the separator specified by "version_path_separator" below.
+# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
+
+# version path separator; As mentioned above, this is the character used to split
+# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
+# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
+# Valid values for version_path_separator are:
+#
+# version_path_separator = :
+# version_path_separator = ;
+# version_path_separator = space
+# version_path_separator = newline
+#
+# Use os.pathsep. Default configuration used for new projects.
+version_path_separator = os
+
+# set to 'true' to search source files recursively
+# in each "version_locations" directory
+# new in Alembic version 1.10
+# recursive_version_locations = false
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+sqlalchemy.url = sqlite:///data/data.db
+
+
+[post_write_hooks]
+# post_write_hooks defines scripts or Python functions that are run
+# on newly generated revision scripts. See the documentation for further
+# detail and examples
+
+# format using "black" - use the console_scripts runner, against the "black" entrypoint
+# hooks = black
+# black.type = console_scripts
+# black.entrypoint = black
+# black.options = -l 79 REVISION_SCRIPT_FILENAME
+
+# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
+# hooks = ruff
+# ruff.type = exec
+# ruff.executable = %(here)s/.venv/bin/ruff
+# ruff.options = --fix REVISION_SCRIPT_FILENAME
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/client/.gitignore b/client/.gitignore
new file mode 100644
index 0000000..a547bf3
--- /dev/null
+++ b/client/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 0000000..167c567
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,28 @@
+## Usage
+
+```bash
+$ npm install # or pnpm install or yarn install
+```
+
+### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
+
+## Available Scripts
+
+In the project directory, you can run:
+
+### `npm run dev`
+
+Runs the app in the development mode.
+Open [http://localhost:5173](http://localhost:5173) to view it in the browser.
+
+### `npm run build`
+
+Builds the app for production to the `dist` folder.
+It correctly bundles Solid in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.
+Your app is ready to be deployed!
+
+## Deployment
+
+Learn more about deploying your application with the [documentations](https://vite.dev/guide/static-deploy.html)
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..ab32d42
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Vite + Solid + TS
+
+
+
+
+
+
diff --git a/client/openapi-ts.config.ts b/client/openapi-ts.config.ts
new file mode 100644
index 0000000..c2ea49e
--- /dev/null
+++ b/client/openapi-ts.config.ts
@@ -0,0 +1,13 @@
+import { defaultPlugins, defineConfig } from "@hey-api/openapi-ts";
+
+export default defineConfig({
+ client: "@hey-api/client-fetch",
+ input: {
+ path: "http://localhost:8000/openapi.json"
+ },
+ output: "src/client",
+ plugins: [
+ ...defaultPlugins,
+ "@tanstack/solid-query"
+ ]
+})
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..ff2901c
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "client",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "generate-client": "openapi-ts"
+ },
+ "dependencies": {
+ "@hey-api/client-fetch": "^0.7.0",
+ "@solidjs/router": "^0.15.3",
+ "@soorria/solid-dropzone": "^1.0.1",
+ "@tanstack/solid-query": "^5.64.1",
+ "bulma": "^1.0.3",
+ "huge-uploader": "^1.0.6",
+ "lucide-solid": "^0.473.0",
+ "oidc-client-ts": "^3.1.0",
+ "solid-js": "^1.9.3"
+ },
+ "devDependencies": {
+ "@hey-api/openapi-ts": "^0.62.1",
+ "event-target-shim": "^6.0.2",
+ "typescript": "~5.6.2",
+ "vite": "^6.0.5",
+ "vite-plugin-solid": "^2.11.0"
+ },
+ "packageManager": "pnpm@8.12.1+sha1.aa961ffce9b6eaa56307d9b5ff7e984f25b7eb58"
+}
diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml
new file mode 100644
index 0000000..7444e6e
--- /dev/null
+++ b/client/pnpm-lock.yaml
@@ -0,0 +1,1518 @@
+lockfileVersion: '6.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+dependencies:
+ '@hey-api/client-fetch':
+ specifier: ^0.7.0
+ version: 0.7.0
+ '@solidjs/router':
+ specifier: ^0.15.3
+ version: 0.15.3(solid-js@1.9.4)
+ '@soorria/solid-dropzone':
+ specifier: ^1.0.1
+ version: 1.0.1(solid-js@1.9.4)
+ '@tanstack/solid-query':
+ specifier: ^5.64.1
+ version: 5.64.1(solid-js@1.9.4)
+ bulma:
+ specifier: ^1.0.3
+ version: 1.0.3
+ huge-uploader:
+ specifier: ^1.0.6
+ version: 1.0.6
+ lucide-solid:
+ specifier: ^0.473.0
+ version: 0.473.0(solid-js@1.9.4)
+ oidc-client-ts:
+ specifier: ^3.1.0
+ version: 3.1.0
+ solid-js:
+ specifier: ^1.9.3
+ version: 1.9.4
+
+devDependencies:
+ '@hey-api/openapi-ts':
+ specifier: ^0.62.1
+ version: 0.62.1(typescript@5.6.3)
+ event-target-shim:
+ specifier: ^6.0.2
+ version: 6.0.2
+ typescript:
+ specifier: ~5.6.2
+ version: 5.6.3
+ vite:
+ specifier: ^6.0.5
+ version: 6.0.7
+ vite-plugin-solid:
+ specifier: ^2.11.0
+ version: 2.11.0(solid-js@1.9.4)(vite@6.0.7)
+
+packages:
+
+ /@ampproject/remapping@2.3.0:
+ resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.8
+ '@jridgewell/trace-mapping': 0.3.25
+ dev: true
+
+ /@babel/code-frame@7.26.2:
+ resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/helper-validator-identifier': 7.25.9
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+ dev: true
+
+ /@babel/compat-data@7.26.5:
+ resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/core@7.26.0:
+ resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ '@babel/code-frame': 7.26.2
+ '@babel/generator': 7.26.5
+ '@babel/helper-compilation-targets': 7.26.5
+ '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0)
+ '@babel/helpers': 7.26.0
+ '@babel/parser': 7.26.5
+ '@babel/template': 7.25.9
+ '@babel/traverse': 7.26.5
+ '@babel/types': 7.26.5
+ convert-source-map: 2.0.0
+ debug: 4.4.0
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@babel/generator@7.26.5:
+ resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/parser': 7.26.5
+ '@babel/types': 7.26.5
+ '@jridgewell/gen-mapping': 0.3.8
+ '@jridgewell/trace-mapping': 0.3.25
+ jsesc: 3.1.0
+ dev: true
+
+ /@babel/helper-compilation-targets@7.26.5:
+ resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/compat-data': 7.26.5
+ '@babel/helper-validator-option': 7.25.9
+ browserslist: 4.24.4
+ lru-cache: 5.1.1
+ semver: 6.3.1
+ dev: true
+
+ /@babel/helper-module-imports@7.18.6:
+ resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@babel/helper-module-imports@7.25.9:
+ resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/traverse': 7.26.5
+ '@babel/types': 7.26.5
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0):
+ resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ dependencies:
+ '@babel/core': 7.26.0
+ '@babel/helper-module-imports': 7.25.9
+ '@babel/helper-validator-identifier': 7.25.9
+ '@babel/traverse': 7.26.5
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@babel/helper-plugin-utils@7.26.5:
+ resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/helper-string-parser@7.25.9:
+ resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/helper-validator-identifier@7.25.9:
+ resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/helper-validator-option@7.25.9:
+ resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /@babel/helpers@7.26.0:
+ resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/template': 7.25.9
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@babel/parser@7.26.5:
+ resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+ dependencies:
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0):
+ resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+ dependencies:
+ '@babel/core': 7.26.0
+ '@babel/helper-plugin-utils': 7.26.5
+ dev: true
+
+ /@babel/template@7.25.9:
+ resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/code-frame': 7.26.2
+ '@babel/parser': 7.26.5
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@babel/traverse@7.26.5:
+ resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/code-frame': 7.26.2
+ '@babel/generator': 7.26.5
+ '@babel/parser': 7.26.5
+ '@babel/template': 7.25.9
+ '@babel/types': 7.26.5
+ debug: 4.4.0
+ globals: 11.12.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@babel/types@7.26.5:
+ resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==}
+ engines: {node: '>=6.9.0'}
+ dependencies:
+ '@babel/helper-string-parser': 7.25.9
+ '@babel/helper-validator-identifier': 7.25.9
+ dev: true
+
+ /@esbuild/aix-ppc64@0.24.2:
+ resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/android-arm64@0.24.2:
+ resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/android-arm@0.24.2:
+ resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/android-x64@0.24.2:
+ resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/darwin-arm64@0.24.2:
+ resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/darwin-x64@0.24.2:
+ resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/freebsd-arm64@0.24.2:
+ resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/freebsd-x64@0.24.2:
+ resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-arm64@0.24.2:
+ resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-arm@0.24.2:
+ resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-ia32@0.24.2:
+ resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-loong64@0.24.2:
+ resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-mips64el@0.24.2:
+ resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-ppc64@0.24.2:
+ resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-riscv64@0.24.2:
+ resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-s390x@0.24.2:
+ resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/linux-x64@0.24.2:
+ resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/netbsd-arm64@0.24.2:
+ resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/netbsd-x64@0.24.2:
+ resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/openbsd-arm64@0.24.2:
+ resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/openbsd-x64@0.24.2:
+ resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/sunos-x64@0.24.2:
+ resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/win32-arm64@0.24.2:
+ resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/win32-ia32@0.24.2:
+ resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@esbuild/win32-x64@0.24.2:
+ resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@hey-api/client-fetch@0.7.0:
+ resolution: {integrity: sha512-jtoAJ74fmt8+bkWmQOsynB1TomUYA2hf95RyUzzB3ksegn9we3ohkKbiMnJhe4Ae2O2KHuNjkW5fgmUoIsnyoQ==}
+ dev: false
+
+ /@hey-api/json-schema-ref-parser@1.0.1:
+ resolution: {integrity: sha512-dBt0A7op9kf4BcK++x6HBYDmvCvnJUZEGe5QytghPFHnMXPyKwDKomwL/v5e9ERk6E0e1GzL/e/y6pWUso9zrQ==}
+ engines: {node: '>= 16'}
+ dependencies:
+ '@jsdevtools/ono': 7.1.3
+ '@types/json-schema': 7.0.15
+ js-yaml: 4.1.0
+ dev: true
+
+ /@hey-api/openapi-ts@0.62.1(typescript@5.6.3):
+ resolution: {integrity: sha512-iCqeu3jhlMBBhAFu3ZCSww5fV4M9xhYhL06QXjwXso4CSfrCpEGw03wTXOphWI1mmZUl8/eNn3DKdP7QpewhHA==}
+ engines: {node: ^18.20.5 || ^20.11.1 || >=22.11.0}
+ hasBin: true
+ peerDependencies:
+ typescript: ^5.5.3
+ dependencies:
+ '@hey-api/json-schema-ref-parser': 1.0.1
+ c12: 2.0.1
+ commander: 13.0.0
+ handlebars: 4.7.8
+ typescript: 5.6.3
+ transitivePeerDependencies:
+ - magicast
+ dev: true
+
+ /@jridgewell/gen-mapping@0.3.8:
+ resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ '@jridgewell/set-array': 1.2.1
+ '@jridgewell/sourcemap-codec': 1.5.0
+ '@jridgewell/trace-mapping': 0.3.25
+ dev: true
+
+ /@jridgewell/resolve-uri@3.1.2:
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
+ /@jridgewell/set-array@1.2.1:
+ resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
+ /@jridgewell/sourcemap-codec@1.5.0:
+ resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
+ dev: true
+
+ /@jridgewell/trace-mapping@0.3.25:
+ resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.5.0
+ dev: true
+
+ /@jsdevtools/ono@7.1.3:
+ resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+ dev: true
+
+ /@rollup/rollup-android-arm-eabi@4.30.1:
+ resolution: {integrity: sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==}
+ cpu: [arm]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-android-arm64@4.30.1:
+ resolution: {integrity: sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-darwin-arm64@4.30.1:
+ resolution: {integrity: sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-darwin-x64@4.30.1:
+ resolution: {integrity: sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-freebsd-arm64@4.30.1:
+ resolution: {integrity: sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-freebsd-x64@4.30.1:
+ resolution: {integrity: sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-arm-gnueabihf@4.30.1:
+ resolution: {integrity: sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-arm-musleabihf@4.30.1:
+ resolution: {integrity: sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-arm64-gnu@4.30.1:
+ resolution: {integrity: sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-arm64-musl@4.30.1:
+ resolution: {integrity: sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-loongarch64-gnu@4.30.1:
+ resolution: {integrity: sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==}
+ cpu: [loong64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-powerpc64le-gnu@4.30.1:
+ resolution: {integrity: sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-riscv64-gnu@4.30.1:
+ resolution: {integrity: sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-s390x-gnu@4.30.1:
+ resolution: {integrity: sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-x64-gnu@4.30.1:
+ resolution: {integrity: sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-linux-x64-musl@4.30.1:
+ resolution: {integrity: sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-win32-arm64-msvc@4.30.1:
+ resolution: {integrity: sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-win32-ia32-msvc@4.30.1:
+ resolution: {integrity: sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@rollup/rollup-win32-x64-msvc@4.30.1:
+ resolution: {integrity: sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /@solidjs/router@0.15.3(solid-js@1.9.4):
+ resolution: {integrity: sha512-iEbW8UKok2Oio7o6Y4VTzLj+KFCmQPGEpm1fS3xixwFBdclFVBvaQVeibl1jys4cujfAK5Kn6+uG2uBm3lxOMw==}
+ peerDependencies:
+ solid-js: ^1.8.6
+ dependencies:
+ solid-js: 1.9.4
+ dev: false
+
+ /@soorria/solid-dropzone@1.0.1(solid-js@1.9.4):
+ resolution: {integrity: sha512-xQ6qkn3IK3IgAz7yyx+AgfmnCnRHkBTqQGjTcS64FDN7YnnvpZB//0rZjJKl5xwG9bGcJ3oyJAHnv2NK/KNSZA==}
+ peerDependencies:
+ solid-js: '>=1.0.0'
+ dependencies:
+ file-selector: 0.6.0
+ solid-js: 1.9.4
+ dev: false
+
+ /@tanstack/query-core@5.64.1:
+ resolution: {integrity: sha512-978Wx4Wl4UJZbmvU/rkaM9cQtXXrbhK0lsz/UZhYIbyKYA8E4LdomTwyh2GHZ4oU0BKKoDH4YlKk2VscCUgNmg==}
+ dev: false
+
+ /@tanstack/solid-query@5.64.1(solid-js@1.9.4):
+ resolution: {integrity: sha512-Omlnn8TfvRWh7Pq2HT8HBVngn0YTY19YOz5VTn3xtFVSnOZky6GKf6+Mwm2ovl8NfVQGeeBQGld4KMwzzc+vkg==}
+ peerDependencies:
+ solid-js: ^1.6.0
+ dependencies:
+ '@tanstack/query-core': 5.64.1
+ solid-js: 1.9.4
+ dev: false
+
+ /@types/babel__core@7.20.5:
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+ dependencies:
+ '@babel/parser': 7.26.5
+ '@babel/types': 7.26.5
+ '@types/babel__generator': 7.6.8
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.20.6
+ dev: true
+
+ /@types/babel__generator@7.6.8:
+ resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==}
+ dependencies:
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@types/babel__template@7.4.4:
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+ dependencies:
+ '@babel/parser': 7.26.5
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@types/babel__traverse@7.20.6:
+ resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==}
+ dependencies:
+ '@babel/types': 7.26.5
+ dev: true
+
+ /@types/estree@1.0.6:
+ resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
+ dev: true
+
+ /@types/json-schema@7.0.15:
+ resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
+ dev: true
+
+ /acorn@8.14.0:
+ resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==}
+ engines: {node: '>=0.4.0'}
+ hasBin: true
+ dev: true
+
+ /argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ dev: true
+
+ /babel-plugin-jsx-dom-expressions@0.39.5(@babel/core@7.26.0):
+ resolution: {integrity: sha512-dwyVkszHRsZCXfFusu3xq1DJS7twhgLrjEpMC1gtTfJG1xSrMMKWWhdl1SFFFNXrvYDsoHiRxSbku/TzLxHNxg==}
+ peerDependencies:
+ '@babel/core': ^7.20.12
+ dependencies:
+ '@babel/core': 7.26.0
+ '@babel/helper-module-imports': 7.18.6
+ '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0)
+ '@babel/types': 7.26.5
+ html-entities: 2.3.3
+ parse5: 7.2.1
+ validate-html-nesting: 1.2.2
+ dev: true
+
+ /babel-preset-solid@1.9.3(@babel/core@7.26.0):
+ resolution: {integrity: sha512-jvlx5wDp8s+bEF9sGFw/84SInXOA51ttkUEroQziKMbxplXThVKt83qB6bDTa1HuLNatdU9FHpFOiQWs1tLQIg==}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+ dependencies:
+ '@babel/core': 7.26.0
+ babel-plugin-jsx-dom-expressions: 0.39.5(@babel/core@7.26.0)
+ dev: true
+
+ /browserslist@4.24.4:
+ resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+ dependencies:
+ caniuse-lite: 1.0.30001692
+ electron-to-chromium: 1.5.83
+ node-releases: 2.0.19
+ update-browserslist-db: 1.1.2(browserslist@4.24.4)
+ dev: true
+
+ /bulma@1.0.3:
+ resolution: {integrity: sha512-9eVXBrXwlU337XUXBjIIq7i88A+tRbJYAjXQjT/21lwam+5tpvKF0R7dCesre9N+HV9c6pzCNEPKrtgvBBes2g==}
+ dev: false
+
+ /c12@2.0.1:
+ resolution: {integrity: sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==}
+ peerDependencies:
+ magicast: ^0.3.5
+ peerDependenciesMeta:
+ magicast:
+ optional: true
+ dependencies:
+ chokidar: 4.0.3
+ confbox: 0.1.8
+ defu: 6.1.4
+ dotenv: 16.4.7
+ giget: 1.2.3
+ jiti: 2.4.2
+ mlly: 1.7.4
+ ohash: 1.1.4
+ pathe: 1.1.2
+ perfect-debounce: 1.0.0
+ pkg-types: 1.3.1
+ rc9: 2.1.2
+ dev: true
+
+ /caniuse-lite@1.0.30001692:
+ resolution: {integrity: sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==}
+ dev: true
+
+ /chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+ dependencies:
+ readdirp: 4.1.1
+ dev: true
+
+ /chownr@2.0.0:
+ resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
+ engines: {node: '>=10'}
+ dev: true
+
+ /citty@0.1.6:
+ resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+ dependencies:
+ consola: 3.4.0
+ dev: true
+
+ /commander@13.0.0:
+ resolution: {integrity: sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ==}
+ engines: {node: '>=18'}
+ dev: true
+
+ /confbox@0.1.8:
+ resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+ dev: true
+
+ /consola@3.4.0:
+ resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+ dev: true
+
+ /convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+ dev: true
+
+ /cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+ dev: true
+
+ /csstype@3.1.3:
+ resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
+
+ /debug@4.4.0:
+ resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.3
+ dev: true
+
+ /defu@6.1.4:
+ resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+ dev: true
+
+ /destr@2.0.3:
+ resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==}
+ dev: true
+
+ /dotenv@16.4.7:
+ resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /electron-to-chromium@1.5.83:
+ resolution: {integrity: sha512-LcUDPqSt+V0QmI47XLzZrz5OqILSMGsPFkDYus22rIbgorSvBYEFqq854ltTmUdHkY92FSdAAvsh4jWEULMdfQ==}
+ dev: true
+
+ /entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+ dev: true
+
+ /esbuild@0.24.2:
+ resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
+ engines: {node: '>=18'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.24.2
+ '@esbuild/android-arm': 0.24.2
+ '@esbuild/android-arm64': 0.24.2
+ '@esbuild/android-x64': 0.24.2
+ '@esbuild/darwin-arm64': 0.24.2
+ '@esbuild/darwin-x64': 0.24.2
+ '@esbuild/freebsd-arm64': 0.24.2
+ '@esbuild/freebsd-x64': 0.24.2
+ '@esbuild/linux-arm': 0.24.2
+ '@esbuild/linux-arm64': 0.24.2
+ '@esbuild/linux-ia32': 0.24.2
+ '@esbuild/linux-loong64': 0.24.2
+ '@esbuild/linux-mips64el': 0.24.2
+ '@esbuild/linux-ppc64': 0.24.2
+ '@esbuild/linux-riscv64': 0.24.2
+ '@esbuild/linux-s390x': 0.24.2
+ '@esbuild/linux-x64': 0.24.2
+ '@esbuild/netbsd-arm64': 0.24.2
+ '@esbuild/netbsd-x64': 0.24.2
+ '@esbuild/openbsd-arm64': 0.24.2
+ '@esbuild/openbsd-x64': 0.24.2
+ '@esbuild/sunos-x64': 0.24.2
+ '@esbuild/win32-arm64': 0.24.2
+ '@esbuild/win32-ia32': 0.24.2
+ '@esbuild/win32-x64': 0.24.2
+ dev: true
+
+ /escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /event-target-shim@3.0.2:
+ resolution: {integrity: sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==}
+ engines: {node: '>=4'}
+ dev: false
+
+ /event-target-shim@6.0.2:
+ resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==}
+ engines: {node: '>=10.13.0'}
+ dev: true
+
+ /execa@8.0.1:
+ resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
+ engines: {node: '>=16.17'}
+ dependencies:
+ cross-spawn: 7.0.6
+ get-stream: 8.0.1
+ human-signals: 5.0.0
+ is-stream: 3.0.0
+ merge-stream: 2.0.0
+ npm-run-path: 5.3.0
+ onetime: 6.0.0
+ signal-exit: 4.1.0
+ strip-final-newline: 3.0.0
+ dev: true
+
+ /file-selector@0.6.0:
+ resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==}
+ engines: {node: '>= 12'}
+ dependencies:
+ tslib: 2.8.1
+ dev: false
+
+ /fs-minipass@2.1.0:
+ resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ minipass: 3.3.6
+ dev: true
+
+ /fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+ dev: true
+
+ /get-stream@8.0.1:
+ resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
+ engines: {node: '>=16'}
+ dev: true
+
+ /giget@1.2.3:
+ resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==}
+ hasBin: true
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.0
+ defu: 6.1.4
+ node-fetch-native: 1.6.4
+ nypm: 0.3.12
+ ohash: 1.1.4
+ pathe: 1.1.2
+ tar: 6.2.1
+ dev: true
+
+ /globals@11.12.0:
+ resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /handlebars@4.7.8:
+ resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
+ engines: {node: '>=0.4.7'}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.8
+ neo-async: 2.6.2
+ source-map: 0.6.1
+ wordwrap: 1.0.0
+ optionalDependencies:
+ uglify-js: 3.19.3
+ dev: true
+
+ /html-entities@2.3.3:
+ resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==}
+ dev: true
+
+ /huge-uploader@1.0.6:
+ resolution: {integrity: sha512-NxMkrpbvqaNLtXzGI64yopeY33o6zrl2uTYy/rBcqAyM7aO5K3f6P6BPCptuBdMllpyU7kJsEaG9QIzXCkXpIQ==}
+ dependencies:
+ event-target-shim: 3.0.2
+ dev: false
+
+ /human-signals@5.0.0:
+ resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
+ engines: {node: '>=16.17.0'}
+ dev: true
+
+ /is-stream@3.0.0:
+ resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dev: true
+
+ /is-what@4.1.16:
+ resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
+ engines: {node: '>=12.13'}
+ dev: true
+
+ /isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ dev: true
+
+ /jiti@2.4.2:
+ resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
+ hasBin: true
+ dev: true
+
+ /js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ dev: true
+
+ /js-yaml@4.1.0:
+ resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+ hasBin: true
+ dependencies:
+ argparse: 2.0.1
+ dev: true
+
+ /jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+ dev: true
+
+ /json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+ dev: true
+
+ /jwt-decode@4.0.0:
+ resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
+ engines: {node: '>=18'}
+ dev: false
+
+ /lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ dependencies:
+ yallist: 3.1.1
+ dev: true
+
+ /lucide-solid@0.473.0(solid-js@1.9.4):
+ resolution: {integrity: sha512-AcmBZle73Z9HOtKI3uXBJ6aRem1kVE4XHuiVrhrIToi8jLlycBewUel6ToBUe2s/5mBtRfMe7ZeVYy6PMqyuOA==}
+ peerDependencies:
+ solid-js: ^1.4.7
+ dependencies:
+ solid-js: 1.9.4
+ dev: false
+
+ /merge-anything@5.1.7:
+ resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==}
+ engines: {node: '>=12.13'}
+ dependencies:
+ is-what: 4.1.16
+ dev: true
+
+ /merge-stream@2.0.0:
+ resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+ dev: true
+
+ /mimic-fn@4.0.0:
+ resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ dev: true
+
+ /minipass@3.3.6:
+ resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
+ engines: {node: '>=8'}
+ dependencies:
+ yallist: 4.0.0
+ dev: true
+
+ /minipass@5.0.0:
+ resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /minizlib@2.1.2:
+ resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ minipass: 3.3.6
+ yallist: 4.0.0
+ dev: true
+
+ /mkdirp@1.0.4:
+ resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+ engines: {node: '>=10'}
+ hasBin: true
+ dev: true
+
+ /mlly@1.7.4:
+ resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
+ dependencies:
+ acorn: 8.14.0
+ pathe: 2.0.2
+ pkg-types: 1.3.1
+ ufo: 1.5.4
+ dev: true
+
+ /ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ dev: true
+
+ /nanoid@3.3.8:
+ resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /neo-async@2.6.2:
+ resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
+ dev: true
+
+ /node-fetch-native@1.6.4:
+ resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==}
+ dev: true
+
+ /node-releases@2.0.19:
+ resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
+ dev: true
+
+ /npm-run-path@5.3.0:
+ resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ dependencies:
+ path-key: 4.0.0
+ dev: true
+
+ /nypm@0.3.12:
+ resolution: {integrity: sha512-D3pzNDWIvgA+7IORhD/IuWzEk4uXv6GsgOxiid4UU3h9oq5IqV1KtPDi63n4sZJ/xcWlr88c0QM2RgN5VbOhFA==}
+ engines: {node: ^14.16.0 || >=16.10.0}
+ hasBin: true
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.0
+ execa: 8.0.1
+ pathe: 1.1.2
+ pkg-types: 1.3.1
+ ufo: 1.5.4
+ dev: true
+
+ /ohash@1.1.4:
+ resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==}
+ dev: true
+
+ /oidc-client-ts@3.1.0:
+ resolution: {integrity: sha512-IDopEXjiwjkmJLYZo6BTlvwOtnlSniWZkKZoXforC/oLZHC9wkIxd25Kwtmo5yKFMMVcsp3JY6bhcNJqdYk8+g==}
+ engines: {node: '>=18'}
+ dependencies:
+ jwt-decode: 4.0.0
+ dev: false
+
+ /onetime@6.0.0:
+ resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
+ engines: {node: '>=12'}
+ dependencies:
+ mimic-fn: 4.0.0
+ dev: true
+
+ /parse5@7.2.1:
+ resolution: {integrity: sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==}
+ dependencies:
+ entities: 4.5.0
+ dev: true
+
+ /path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /path-key@4.0.0:
+ resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /pathe@1.1.2:
+ resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+ dev: true
+
+ /pathe@2.0.2:
+ resolution: {integrity: sha512-15Ztpk+nov8DR524R4BF7uEuzESgzUEAV4Ah7CUMNGXdE5ELuvxElxGXndBl32vMSsWa1jpNf22Z+Er3sKwq+w==}
+ dev: true
+
+ /perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+ dev: true
+
+ /picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+ dev: true
+
+ /pkg-types@1.3.1:
+ resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
+ dependencies:
+ confbox: 0.1.8
+ mlly: 1.7.4
+ pathe: 2.0.2
+ dev: true
+
+ /postcss@8.5.1:
+ resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.8
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+ dev: true
+
+ /rc9@2.1.2:
+ resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+ dependencies:
+ defu: 6.1.4
+ destr: 2.0.3
+ dev: true
+
+ /readdirp@4.1.1:
+ resolution: {integrity: sha512-h80JrZu/MHUZCyHu5ciuoI0+WxsCxzxJTILn6Fs8rxSnFPh+UVHYfeIxK1nVGugMqkfC4vJcBOYbkfkwYK0+gw==}
+ engines: {node: '>= 14.18.0'}
+ dev: true
+
+ /rollup@4.30.1:
+ resolution: {integrity: sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+ dependencies:
+ '@types/estree': 1.0.6
+ optionalDependencies:
+ '@rollup/rollup-android-arm-eabi': 4.30.1
+ '@rollup/rollup-android-arm64': 4.30.1
+ '@rollup/rollup-darwin-arm64': 4.30.1
+ '@rollup/rollup-darwin-x64': 4.30.1
+ '@rollup/rollup-freebsd-arm64': 4.30.1
+ '@rollup/rollup-freebsd-x64': 4.30.1
+ '@rollup/rollup-linux-arm-gnueabihf': 4.30.1
+ '@rollup/rollup-linux-arm-musleabihf': 4.30.1
+ '@rollup/rollup-linux-arm64-gnu': 4.30.1
+ '@rollup/rollup-linux-arm64-musl': 4.30.1
+ '@rollup/rollup-linux-loongarch64-gnu': 4.30.1
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.30.1
+ '@rollup/rollup-linux-riscv64-gnu': 4.30.1
+ '@rollup/rollup-linux-s390x-gnu': 4.30.1
+ '@rollup/rollup-linux-x64-gnu': 4.30.1
+ '@rollup/rollup-linux-x64-musl': 4.30.1
+ '@rollup/rollup-win32-arm64-msvc': 4.30.1
+ '@rollup/rollup-win32-ia32-msvc': 4.30.1
+ '@rollup/rollup-win32-x64-msvc': 4.30.1
+ fsevents: 2.3.3
+ dev: true
+
+ /semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+ dev: true
+
+ /seroval-plugins@1.2.0(seroval@1.2.0):
+ resolution: {integrity: sha512-hULTbfzSe81jGWLH8TAJjkEvw6JWMqOo9Uq+4V4vg+HNq53hyHldM9ZOfjdzokcFysiTp9aFdV2vJpZFqKeDjQ==}
+ engines: {node: '>=10'}
+ peerDependencies:
+ seroval: ^1.0
+ dependencies:
+ seroval: 1.2.0
+
+ /seroval@1.2.0:
+ resolution: {integrity: sha512-GURoU99ko2UiAgUC3qDCk59Jb3Ss4Po8VIMGkG8j5PFo2Q7y0YSMP8QG9NuL/fJCoTz9V1XZUbpNIMXPOfaGpA==}
+ engines: {node: '>=10'}
+
+ /shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+ dependencies:
+ shebang-regex: 3.0.0
+ dev: true
+
+ /shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /signal-exit@4.1.0:
+ resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
+ engines: {node: '>=14'}
+ dev: true
+
+ /solid-js@1.9.4:
+ resolution: {integrity: sha512-ipQl8FJ31bFUoBNScDQTG3BjN6+9Rg+Q+f10bUbnO6EOTTf5NGerJeHc7wyu5I4RMHEl/WwZwUmy/PTRgxxZ8g==}
+ dependencies:
+ csstype: 3.1.3
+ seroval: 1.2.0
+ seroval-plugins: 1.2.0(seroval@1.2.0)
+
+ /solid-refresh@0.6.3(solid-js@1.9.4):
+ resolution: {integrity: sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==}
+ peerDependencies:
+ solid-js: ^1.3
+ dependencies:
+ '@babel/generator': 7.26.5
+ '@babel/helper-module-imports': 7.25.9
+ '@babel/types': 7.26.5
+ solid-js: 1.9.4
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /source-map-js@1.2.1:
+ resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /source-map@0.6.1:
+ resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /strip-final-newline@3.0.0:
+ resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
+ engines: {node: '>=12'}
+ dev: true
+
+ /tar@6.2.1:
+ resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
+ engines: {node: '>=10'}
+ dependencies:
+ chownr: 2.0.0
+ fs-minipass: 2.1.0
+ minipass: 5.0.0
+ minizlib: 2.1.2
+ mkdirp: 1.0.4
+ yallist: 4.0.0
+ dev: true
+
+ /tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ dev: false
+
+ /typescript@5.6.3:
+ resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
+ engines: {node: '>=14.17'}
+ hasBin: true
+ dev: true
+
+ /ufo@1.5.4:
+ resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
+ dev: true
+
+ /uglify-js@3.19.3:
+ resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
+ engines: {node: '>=0.8.0'}
+ hasBin: true
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /update-browserslist-db@1.1.2(browserslist@4.24.4):
+ resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+ dependencies:
+ browserslist: 4.24.4
+ escalade: 3.2.0
+ picocolors: 1.1.1
+ dev: true
+
+ /validate-html-nesting@1.2.2:
+ resolution: {integrity: sha512-hGdgQozCsQJMyfK5urgFcWEqsSSrK63Awe0t/IMR0bZ0QMtnuaiHzThW81guu3qx9abLi99NEuiaN6P9gVYsNg==}
+ dev: true
+
+ /vite-plugin-solid@2.11.0(solid-js@1.9.4)(vite@6.0.7):
+ resolution: {integrity: sha512-G+NiwDj4EAeUE0wt3Ur9f+Lt9oMUuLd0FIxYuqwJSqRacKQRteCwUFzNy8zMEt88xWokngQhiFjfJMhjc1fDXw==}
+ peerDependencies:
+ '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.*
+ solid-js: ^1.7.2
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+ peerDependenciesMeta:
+ '@testing-library/jest-dom':
+ optional: true
+ dependencies:
+ '@babel/core': 7.26.0
+ '@types/babel__core': 7.20.5
+ babel-preset-solid: 1.9.3(@babel/core@7.26.0)
+ merge-anything: 5.1.7
+ solid-js: 1.9.4
+ solid-refresh: 0.6.3(solid-js@1.9.4)
+ vite: 6.0.7
+ vitefu: 1.0.5(vite@6.0.7)
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /vite@6.0.7:
+ resolution: {integrity: sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ dependencies:
+ esbuild: 0.24.2
+ postcss: 8.5.1
+ rollup: 4.30.1
+ optionalDependencies:
+ fsevents: 2.3.3
+ dev: true
+
+ /vitefu@1.0.5(vite@6.0.7):
+ resolution: {integrity: sha512-h4Vflt9gxODPFNGPwp4zAMZRpZR7eslzwH2c5hn5kNZ5rhnKyRJ50U+yGCdc2IRaBs8O4haIgLNGrV5CrpMsCA==}
+ peerDependencies:
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+ peerDependenciesMeta:
+ vite:
+ optional: true
+ dependencies:
+ vite: 6.0.7
+ dev: true
+
+ /which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+ dependencies:
+ isexe: 2.0.0
+ dev: true
+
+ /wordwrap@1.0.0:
+ resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
+ dev: true
+
+ /yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ dev: true
+
+ /yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+ dev: true
diff --git a/client/public/vite.svg b/client/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/client/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/admin-layout.tsx b/client/src/admin-layout.tsx
new file mode 100644
index 0000000..9730137
--- /dev/null
+++ b/client/src/admin-layout.tsx
@@ -0,0 +1,55 @@
+import { A } from "@solidjs/router";
+import { createQuery } from "@tanstack/solid-query";
+import { children, createSignal, ErrorBoundary, ParentProps, Suspense } from "solid-js";
+import { readUserOptions } from "./client/@tanstack/solid-query.gen";
+import { logout } from "./auth";
+
+export default function AdminLayout(props: ParentProps) {
+ const content = children(() => props.children);
+ const [navbarToggled, setNavbarToggled] = createSignal(false);
+
+ const userQuery = createQuery(() => ({
+ ...readUserOptions(),
+ }));
+
+ return <>
+
+
+
+
+
+
+ {content()}
+
+ >;
+}
diff --git a/client/src/auth.ts b/client/src/auth.ts
new file mode 100644
index 0000000..b51cc8a
--- /dev/null
+++ b/client/src/auth.ts
@@ -0,0 +1,81 @@
+import { User, UserManager, WebStorageStateStore } from "oidc-client-ts";
+import { createSignal } from "solid-js";
+import { getAppConfig } from "./client";
+
+let userManager: UserManager | null = null;
+
+export async function getUserManager() {
+ if (userManager) return userManager;
+
+ const res = await getAppConfig();
+ const config = res.data;
+
+ if (!config) {
+ throw new Error("Failed to get app configuration from server");
+ }
+
+ userManager = new UserManager({
+ authority: config.oidc_authority,
+ client_id: config.oidc_client_id,
+ redirect_uri: window.location.origin + "/callback",
+ response_type: "code",
+ scope: config.oidc_scopes.join(" "),
+ userStore: new WebStorageStateStore({
+ store: window.localStorage,
+ }),
+ automaticSilentRenew: false,
+ });
+
+ return userManager;
+}
+
+const [user, setUser] = createSignal(null);
+
+export async function login() {
+ await (await getUserManager()).signinRedirect();
+}
+
+export async function logout() {
+ await (await getUserManager()).signoutRedirect();
+ setUser(null);
+}
+
+export async function getToken() {
+ const user = await (await getUserManager()).getUser();
+ return user?.access_token;
+}
+
+export async function fetchUser() {
+ const currentUser = await (await getUserManager()).getUser();
+ if (currentUser && !currentUser.expired) {
+ setUser(currentUser);
+ } else {
+ await refreshToken();
+ }
+}
+
+export async function refreshToken() {
+ try {
+ const currentUser = await (await getUserManager()).getUser();
+ if (!currentUser?.refresh_token) {
+ console.error("No refresh token available, re-authentication required");
+ setUser(null);
+ return;
+ }
+
+ const refreshedUser = await (await getUserManager()).signinSilent();
+
+ if (refreshedUser) {
+ console.log("Token refreshed");
+ setUser(refreshedUser);
+ } else {
+ console.error("Token refresh failed");
+ setUser(null);
+ }
+ } catch (err) {
+ console.error("Error refreshing token:", err);
+ setUser(null);
+ }
+}
+
+export { user };
diff --git a/client/src/client/@tanstack/solid-query.gen.ts b/client/src/client/@tanstack/solid-query.gen.ts
new file mode 100644
index 0000000..d7830bc
--- /dev/null
+++ b/client/src/client/@tanstack/solid-query.gen.ts
@@ -0,0 +1,373 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import type { Options } from '@hey-api/client-fetch';
+import { queryOptions, type MutationOptions } from '@tanstack/solid-query';
+import type { GetAppConfigData, ReadUserData, ReadPodcastsData, CreatePodcastData, CreatePodcastError, CreatePodcastResponse, DeletePodcastData, DeletePodcastError, ReadPodcastData, UpdatePodcastData, UpdatePodcastError, UpdatePodcastResponse, UpdatePodcastImageData, UpdatePodcastImageError, UpdatePodcastImageResponse, ReadEpisodesData, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, UpdateEpisodeData, UpdateEpisodeError, UpdateEpisodeResponse, EpisodeAdditionalUploadData, EpisodeAdditionalUploadError, EpisodeAdditionalUploadResponse, GetPodcastFeedData, GetEpisodeOrCoverData } from '../types.gen';
+import { getAppConfig, readUser, readPodcasts, createPodcast, deletePodcast, readPodcast, updatePodcast, updatePodcastImage, readEpisodes, adminUploadEpisode, deleteEpisode, readEpisode, updateEpisode, episodeAdditionalUpload, getPodcastFeed, getEpisodeOrCover, client } from '../sdk.gen';
+
+type QueryKey = [
+ Pick & {
+ _id: string;
+ _infinite?: boolean;
+ }
+];
+
+const createQueryKey = (id: string, options?: TOptions, infinite?: boolean): QueryKey[0] => {
+ const params: QueryKey[0] = { _id: id, baseUrl: (options?.client ?? client).getConfig().baseUrl } as QueryKey[0];
+ if (infinite) {
+ params._infinite = infinite;
+ }
+ if (options?.body) {
+ params.body = options.body;
+ }
+ if (options?.headers) {
+ params.headers = options.headers;
+ }
+ if (options?.path) {
+ params.path = options.path;
+ }
+ if (options?.query) {
+ params.query = options.query;
+ }
+ return params;
+};
+
+export const getAppConfigQueryKey = (options?: Options) => [
+ createQueryKey('getAppConfig', options)
+];
+
+export const getAppConfigOptions = (options?: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await getAppConfig({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: getAppConfigQueryKey(options)
+ });
+};
+
+export const readUserQueryKey = (options?: Options) => [
+ createQueryKey('readUser', options)
+];
+
+export const readUserOptions = (options?: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await readUser({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: readUserQueryKey(options)
+ });
+};
+
+export const readPodcastsQueryKey = (options?: Options) => [
+ createQueryKey('readPodcasts', options)
+];
+
+export const readPodcastsOptions = (options?: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await readPodcasts({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: readPodcastsQueryKey(options)
+ });
+};
+
+export const createPodcastQueryKey = (options: Options) => [
+ createQueryKey('createPodcast', options)
+];
+
+export const createPodcastOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await createPodcast({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: createPodcastQueryKey(options)
+ });
+};
+
+export const createPodcastMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await createPodcast({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const deletePodcastMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await deletePodcast({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const readPodcastQueryKey = (options: Options) => [
+ createQueryKey('readPodcast', options)
+];
+
+export const readPodcastOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await readPodcast({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: readPodcastQueryKey(options)
+ });
+};
+
+export const updatePodcastMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await updatePodcast({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const updatePodcastImageQueryKey = (options: Options) => [
+ createQueryKey('updatePodcastImage', options)
+];
+
+export const updatePodcastImageOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await updatePodcastImage({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: updatePodcastImageQueryKey(options)
+ });
+};
+
+export const updatePodcastImageMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await updatePodcastImage({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const readEpisodesQueryKey = (options: Options) => [
+ createQueryKey('readEpisodes', options)
+];
+
+export const readEpisodesOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await readEpisodes({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: readEpisodesQueryKey(options)
+ });
+};
+
+export const adminUploadEpisodeQueryKey = (options: Options) => [
+ createQueryKey('adminUploadEpisode', options)
+];
+
+export const adminUploadEpisodeOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await adminUploadEpisode({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: adminUploadEpisodeQueryKey(options)
+ });
+};
+
+export const adminUploadEpisodeMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await adminUploadEpisode({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const deleteEpisodeMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await deleteEpisode({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const readEpisodeQueryKey = (options: Options) => [
+ createQueryKey('readEpisode', options)
+];
+
+export const readEpisodeOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await readEpisode({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: readEpisodeQueryKey(options)
+ });
+};
+
+export const updateEpisodeMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await updateEpisode({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const episodeAdditionalUploadQueryKey = (options: Options) => [
+ createQueryKey('episodeAdditionalUpload', options)
+];
+
+export const episodeAdditionalUploadOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await episodeAdditionalUpload({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: episodeAdditionalUploadQueryKey(options)
+ });
+};
+
+export const episodeAdditionalUploadMutation = (options?: Partial>) => {
+ const mutationOptions: MutationOptions> = {
+ mutationFn: async (localOptions) => {
+ const { data } = await episodeAdditionalUpload({
+ ...options,
+ ...localOptions,
+ throwOnError: true
+ });
+ return data;
+ }
+ };
+ return mutationOptions;
+};
+
+export const getPodcastFeedQueryKey = (options: Options) => [
+ createQueryKey('getPodcastFeed', options)
+];
+
+export const getPodcastFeedOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await getPodcastFeed({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: getPodcastFeedQueryKey(options)
+ });
+};
+
+export const getEpisodeOrCoverQueryKey = (options: Options) => [
+ createQueryKey('getEpisodeOrCover', options)
+];
+
+export const getEpisodeOrCoverOptions = (options: Options) => {
+ return queryOptions({
+ queryFn: async ({ queryKey, signal }) => {
+ const { data } = await getEpisodeOrCover({
+ ...options,
+ ...queryKey[0],
+ signal,
+ throwOnError: true
+ });
+ return data;
+ },
+ queryKey: getEpisodeOrCoverQueryKey(options)
+ });
+};
\ No newline at end of file
diff --git a/client/src/client/index.ts b/client/src/client/index.ts
new file mode 100644
index 0000000..e64537d
--- /dev/null
+++ b/client/src/client/index.ts
@@ -0,0 +1,3 @@
+// This file is auto-generated by @hey-api/openapi-ts
+export * from './types.gen';
+export * from './sdk.gen';
\ No newline at end of file
diff --git a/client/src/client/sdk.gen.ts b/client/src/client/sdk.gen.ts
new file mode 100644
index 0000000..e79cf16
--- /dev/null
+++ b/client/src/client/sdk.gen.ts
@@ -0,0 +1,193 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { createClient, createConfig, type Options, formDataBodySerializer } from '@hey-api/client-fetch';
+import type { GetAppConfigData, GetAppConfigResponse, ReadUserData, ReadUserResponse, ReadPodcastsData, ReadPodcastsResponse, CreatePodcastData, CreatePodcastResponse, CreatePodcastError, DeletePodcastData, DeletePodcastError, ReadPodcastData, ReadPodcastResponse, ReadPodcastError, UpdatePodcastData, UpdatePodcastResponse, UpdatePodcastError, UpdatePodcastImageData, UpdatePodcastImageResponse, UpdatePodcastImageError, ReadEpisodesData, ReadEpisodesResponse, ReadEpisodesError, AdminUploadEpisodeData, AdminUploadEpisodeError, DeleteEpisodeData, DeleteEpisodeError, ReadEpisodeData, ReadEpisodeResponse, ReadEpisodeError, UpdateEpisodeData, UpdateEpisodeResponse, UpdateEpisodeError, EpisodeAdditionalUploadData, EpisodeAdditionalUploadResponse, EpisodeAdditionalUploadError, GetPodcastFeedData, GetPodcastFeedError, GetEpisodeOrCoverData, GetEpisodeOrCoverError } from './types.gen';
+
+export const client = createClient(createConfig());
+
+/**
+ * Get App Config
+ */
+export const getAppConfig = (options?: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/config',
+ ...options
+ });
+};
+
+/**
+ * Read User
+ */
+export const readUser = (options?: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/user',
+ ...options
+ });
+};
+
+/**
+ * Read Podcasts
+ */
+export const readPodcasts = (options?: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/podcasts',
+ ...options
+ });
+};
+
+/**
+ * Create Podcast
+ */
+export const createPodcast = (options: Options) => {
+ return (options?.client ?? client).post({
+ url: '/api/podcasts',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Delete Podcast
+ */
+export const deletePodcast = (options: Options) => {
+ return (options?.client ?? client).delete({
+ url: '/api/podcasts/{podcast_id}',
+ ...options
+ });
+};
+
+/**
+ * Read Podcast
+ */
+export const readPodcast = (options: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/podcasts/{podcast_id}',
+ ...options
+ });
+};
+
+/**
+ * Update Podcast
+ */
+export const updatePodcast = (options: Options) => {
+ return (options?.client ?? client).patch({
+ url: '/api/podcasts/{podcast_id}',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Update Podcast Image
+ */
+export const updatePodcastImage = (options: Options) => {
+ return (options?.client ?? client).post({
+ ...formDataBodySerializer,
+ url: '/api/podcasts/{podcast_id}/image',
+ ...options,
+ headers: {
+ 'Content-Type': null,
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Read Episodes
+ */
+export const readEpisodes = (options: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/podcasts/{podcast_id}/episodes',
+ ...options
+ });
+};
+
+/**
+ * Admin Upload Episode
+ */
+export const adminUploadEpisode = (options: Options) => {
+ return (options?.client ?? client).post({
+ ...formDataBodySerializer,
+ url: '/api/podcasts/{podcast_id}/episodes',
+ ...options,
+ headers: {
+ 'Content-Type': null,
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Delete Episode
+ */
+export const deleteEpisode = (options: Options) => {
+ return (options?.client ?? client).delete({
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
+ ...options
+ });
+};
+
+/**
+ * Read Episode
+ */
+export const readEpisode = (options: Options) => {
+ return (options?.client ?? client).get({
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
+ ...options
+ });
+};
+
+/**
+ * Update Episode
+ */
+export const updateEpisode = (options: Options) => {
+ return (options?.client ?? client).patch({
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Episode Additional Upload
+ */
+export const episodeAdditionalUpload = (options: Options) => {
+ return (options?.client ?? client).post({
+ ...formDataBodySerializer,
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}',
+ ...options,
+ headers: {
+ 'Content-Type': null,
+ ...options?.headers
+ }
+ });
+};
+
+/**
+ * Get Podcast Feed
+ */
+export const getPodcastFeed = (options: Options) => {
+ return (options?.client ?? client).get({
+ url: '/{podcast_id}.xml',
+ ...options
+ });
+};
+
+/**
+ * Get Episode Or Cover
+ */
+export const getEpisodeOrCover = (options: Options) => {
+ return (options?.client ?? client).get({
+ url: '/{podcast_id}/{filename}',
+ ...options
+ });
+};
\ No newline at end of file
diff --git a/client/src/client/types.gen.ts b/client/src/client/types.gen.ts
new file mode 100644
index 0000000..3d27488
--- /dev/null
+++ b/client/src/client/types.gen.ts
@@ -0,0 +1,464 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+export type AppConfig = {
+ oidc_authority: string;
+ oidc_client_id: string;
+ oidc_scopes: Array;
+};
+
+export type BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost = {
+ file: Blob | File;
+};
+
+export type BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost = {
+ file: Blob | File;
+};
+
+export type BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost = {
+ image: Blob | File;
+};
+
+export type CurrentUser = {
+ user_id: string;
+ user_name: string;
+};
+
+export type HttpValidationError = {
+ detail?: Array;
+};
+
+export type PodcastCreate = {
+ name: string;
+ description?: string;
+ explicit?: boolean;
+};
+
+export type PodcastEpisodePublic = {
+ name: string;
+ duration?: number | null;
+ description?: string | null;
+ file_hash: string;
+ file_size: number;
+ publish_date?: string;
+ id: string;
+ podcast_id: string;
+ description_html: string | null;
+};
+
+export type PodcastEpisodeUpdate = {
+ name: string | null;
+ description: string | null;
+ publish_date?: string | null;
+};
+
+export type PodcastPublic = {
+ name: string;
+ description: string;
+ explicit?: boolean;
+ id: string;
+ image_filename?: string | null;
+};
+
+export type PodcastUpdate = {
+ name: string | null;
+ description: string | null;
+ explicit?: boolean | null;
+};
+
+export type ValidationError = {
+ loc: Array;
+ msg: string;
+ type: string;
+};
+
+export type GetAppConfigData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/config';
+};
+
+export type GetAppConfigResponses = {
+ /**
+ * Successful Response
+ */
+ 200: AppConfig;
+};
+
+export type GetAppConfigResponse = GetAppConfigResponses[keyof GetAppConfigResponses];
+
+export type ReadUserData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/user';
+};
+
+export type ReadUserResponses = {
+ /**
+ * Successful Response
+ */
+ 200: CurrentUser;
+};
+
+export type ReadUserResponse = ReadUserResponses[keyof ReadUserResponses];
+
+export type ReadPodcastsData = {
+ body?: never;
+ path?: never;
+ query?: never;
+ url: '/api/podcasts';
+};
+
+export type ReadPodcastsResponses = {
+ /**
+ * Successful Response
+ */
+ 200: Array;
+};
+
+export type ReadPodcastsResponse = ReadPodcastsResponses[keyof ReadPodcastsResponses];
+
+export type CreatePodcastData = {
+ body: PodcastCreate;
+ path?: never;
+ query?: never;
+ url: '/api/podcasts';
+};
+
+export type CreatePodcastErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type CreatePodcastError = CreatePodcastErrors[keyof CreatePodcastErrors];
+
+export type CreatePodcastResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastPublic;
+};
+
+export type CreatePodcastResponse = CreatePodcastResponses[keyof CreatePodcastResponses];
+
+export type DeletePodcastData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}';
+};
+
+export type DeletePodcastErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type DeletePodcastError = DeletePodcastErrors[keyof DeletePodcastErrors];
+
+export type DeletePodcastResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type ReadPodcastData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}';
+};
+
+export type ReadPodcastErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type ReadPodcastError = ReadPodcastErrors[keyof ReadPodcastErrors];
+
+export type ReadPodcastResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastPublic;
+};
+
+export type ReadPodcastResponse = ReadPodcastResponses[keyof ReadPodcastResponses];
+
+export type UpdatePodcastData = {
+ body?: PodcastUpdate;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}';
+};
+
+export type UpdatePodcastErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type UpdatePodcastError = UpdatePodcastErrors[keyof UpdatePodcastErrors];
+
+export type UpdatePodcastResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastPublic;
+};
+
+export type UpdatePodcastResponse = UpdatePodcastResponses[keyof UpdatePodcastResponses];
+
+export type UpdatePodcastImageData = {
+ body: BodyUpdatePodcastImageApiPodcastsPodcastIdImagePost;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/image';
+};
+
+export type UpdatePodcastImageErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type UpdatePodcastImageError = UpdatePodcastImageErrors[keyof UpdatePodcastImageErrors];
+
+export type UpdatePodcastImageResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastPublic;
+};
+
+export type UpdatePodcastImageResponse = UpdatePodcastImageResponses[keyof UpdatePodcastImageResponses];
+
+export type ReadEpisodesData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes';
+};
+
+export type ReadEpisodesErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type ReadEpisodesError = ReadEpisodesErrors[keyof ReadEpisodesErrors];
+
+export type ReadEpisodesResponses = {
+ /**
+ * Successful Response
+ */
+ 200: Array;
+};
+
+export type ReadEpisodesResponse = ReadEpisodesResponses[keyof ReadEpisodesResponses];
+
+export type AdminUploadEpisodeData = {
+ body: BodyAdminUploadEpisodeApiPodcastsPodcastIdEpisodesPost;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes';
+};
+
+export type AdminUploadEpisodeErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type AdminUploadEpisodeError = AdminUploadEpisodeErrors[keyof AdminUploadEpisodeErrors];
+
+export type AdminUploadEpisodeResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type DeleteEpisodeData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ episode_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
+};
+
+export type DeleteEpisodeErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type DeleteEpisodeError = DeleteEpisodeErrors[keyof DeleteEpisodeErrors];
+
+export type DeleteEpisodeResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type ReadEpisodeData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ episode_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
+};
+
+export type ReadEpisodeErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type ReadEpisodeError = ReadEpisodeErrors[keyof ReadEpisodeErrors];
+
+export type ReadEpisodeResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastEpisodePublic;
+};
+
+export type ReadEpisodeResponse = ReadEpisodeResponses[keyof ReadEpisodeResponses];
+
+export type UpdateEpisodeData = {
+ body: PodcastEpisodeUpdate;
+ path: {
+ podcast_id: string;
+ episode_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
+};
+
+export type UpdateEpisodeErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type UpdateEpisodeError = UpdateEpisodeErrors[keyof UpdateEpisodeErrors];
+
+export type UpdateEpisodeResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastEpisodePublic;
+};
+
+export type UpdateEpisodeResponse = UpdateEpisodeResponses[keyof UpdateEpisodeResponses];
+
+export type EpisodeAdditionalUploadData = {
+ body: BodyEpisodeAdditionalUploadApiPodcastsPodcastIdEpisodesEpisodeIdPost;
+ path: {
+ podcast_id: string;
+ episode_id: string;
+ };
+ query?: never;
+ url: '/api/podcasts/{podcast_id}/episodes/{episode_id}';
+};
+
+export type EpisodeAdditionalUploadErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type EpisodeAdditionalUploadError = EpisodeAdditionalUploadErrors[keyof EpisodeAdditionalUploadErrors];
+
+export type EpisodeAdditionalUploadResponses = {
+ /**
+ * Successful Response
+ */
+ 200: PodcastEpisodePublic;
+};
+
+export type EpisodeAdditionalUploadResponse = EpisodeAdditionalUploadResponses[keyof EpisodeAdditionalUploadResponses];
+
+export type GetPodcastFeedData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ };
+ query?: never;
+ url: '/{podcast_id}.xml';
+};
+
+export type GetPodcastFeedErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetPodcastFeedError = GetPodcastFeedErrors[keyof GetPodcastFeedErrors];
+
+export type GetPodcastFeedResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type GetEpisodeOrCoverData = {
+ body?: never;
+ path: {
+ podcast_id: string;
+ filename: string;
+ };
+ query?: never;
+ url: '/{podcast_id}/{filename}';
+};
+
+export type GetEpisodeOrCoverErrors = {
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetEpisodeOrCoverError = GetEpisodeOrCoverErrors[keyof GetEpisodeOrCoverErrors];
+
+export type GetEpisodeOrCoverResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
\ No newline at end of file
diff --git a/client/src/components/AuthProvider.tsx b/client/src/components/AuthProvider.tsx
new file mode 100644
index 0000000..f1316b1
--- /dev/null
+++ b/client/src/components/AuthProvider.tsx
@@ -0,0 +1,28 @@
+import { createContext, createEffect, createSignal, ParentComponent } from "solid-js";
+import { fetchUser, login, logout, user } from "../auth";
+import { User } from "oidc-client-ts";
+
+interface AuthContextType {
+ user: () => User | null;
+ login: () => Promise;
+ logout: () => Promise;
+}
+
+export const AuthContext = createContext(undefined);
+
+const AuthProvider: ParentComponent = (props) => {
+ const [isLoading, setIsLoading] = createSignal(true);
+
+ createEffect(async () => {
+ await fetchUser();
+ setIsLoading(false);
+ });
+
+ return (
+
+ {isLoading() ? Loading...
: props.children}
+
+ );
+}
+
+export default AuthProvider;
diff --git a/client/src/components/NotFound.tsx b/client/src/components/NotFound.tsx
new file mode 100644
index 0000000..2428910
--- /dev/null
+++ b/client/src/components/NotFound.tsx
@@ -0,0 +1,10 @@
+export default function NotFound() {
+ return (
+
+
+
Not Found
+ Go Back
+
+
+ )
+}
diff --git a/client/src/components/Protected.tsx b/client/src/components/Protected.tsx
new file mode 100644
index 0000000..b3234a3
--- /dev/null
+++ b/client/src/components/Protected.tsx
@@ -0,0 +1,7 @@
+import { Navigate } from "@solidjs/router";
+import { user } from "../auth";
+import { ParentProps } from "solid-js";
+
+export default function Protected(props: ParentProps) {
+ return user() ? props.children : ;
+}
diff --git a/client/src/components/UploadEpisodeAdditional.tsx b/client/src/components/UploadEpisodeAdditional.tsx
new file mode 100644
index 0000000..4969430
--- /dev/null
+++ b/client/src/components/UploadEpisodeAdditional.tsx
@@ -0,0 +1,67 @@
+import { createDropzone } from "@soorria/solid-dropzone";
+import { UploadIcon } from "lucide-solid";
+import { createSignal, Show } from "solid-js";
+import { createMutation, useQueryClient } from "@tanstack/solid-query";
+import { episodeAdditionalUploadMutation, readEpisodesQueryKey, readEpisodeQueryKey } from "../client/@tanstack/solid-query.gen";
+import { PodcastEpisodePublic } from "../client";
+
+export default function UploadEpisodeAdditional(props: { podcastId: string, episodeId: string }) {
+ const queryClient = useQueryClient();
+
+ const [uploading, setUploading] = createSignal(false);
+ const [statusText, setStatusText] = createSignal(null);
+ const uploadAdditional = createMutation(() => ({
+ ...episodeAdditionalUploadMutation(),
+ onSuccess(data, vars) {
+ queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
+ queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data);
+ }
+ }))
+
+ const dropzone = createDropzone({
+ onDrop(files: File[]) {
+ if (!files[0]) {
+ throw new Error("No file was uploaded");
+ }
+
+ setStatusText("Uploading...");
+ setUploading(true);
+
+ uploadAdditional.mutate({
+ body: {
+ file: files[0],
+ },
+ path: {
+ podcast_id: props.podcastId,
+ episode_id: props.episodeId
+ }
+ }, {
+ onSuccess() {
+ setStatusText("Additional file uploaded!");
+ setUploading(false);
+ },
+ onError(error, _variables, _context) {
+ setStatusText(`Error uploading: ${error.detail}`);
+ setUploading(false);
+ },
+ });
+ }
+ });
+
+ return <>
+
+
+ {statusText()}
+
+
+
+
+
+
+
+ {dropzone.isDragActive ? "Drop extra file here..." : "Drag an extra file here to upload, or click to open"}
+
+
+
+ >;
+}
diff --git a/client/src/components/error.tsx b/client/src/components/error.tsx
new file mode 100644
index 0000000..1afcb10
--- /dev/null
+++ b/client/src/components/error.tsx
@@ -0,0 +1,14 @@
+export default function Error(props: { message?: any, reset?: () => void }) {
+ return (
+
+
+
Uh Oh!
+
Something has gone wrong.
+ {props.message && <>
+
Error: {props.message.toString()}
+ >}
+ {props.reset &&
Retry }
+
+
+ );
+}
diff --git a/client/src/components/loading.tsx b/client/src/components/loading.tsx
new file mode 100644
index 0000000..df717e7
--- /dev/null
+++ b/client/src/components/loading.tsx
@@ -0,0 +1,10 @@
+export default function Loading() {
+ return (
+
+ );
+}
diff --git a/client/src/components/upload-image.tsx b/client/src/components/upload-image.tsx
new file mode 100644
index 0000000..31d618b
--- /dev/null
+++ b/client/src/components/upload-image.tsx
@@ -0,0 +1,66 @@
+import { createDropzone } from "@soorria/solid-dropzone";
+import { UploadIcon } from "lucide-solid";
+import { createSignal, Show } from "solid-js";
+import { createMutation, useQueryClient } from "@tanstack/solid-query";
+import { readPodcastQueryKey, readPodcastsQueryKey, updatePodcastImageMutation } from "../client/@tanstack/solid-query.gen";
+import { PodcastPublic } from "../client";
+
+export default function UploadImage(props: { podcastId: string }) {
+ const queryClient = useQueryClient();
+
+ const [uploading, setUploading] = createSignal(false);
+ const [statusText, setStatusText] = createSignal(null);
+ const updatePodcastImage = createMutation(() => ({
+ ...updatePodcastImageMutation(),
+ onSuccess(data, vars) {
+ queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data);
+ queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
+ },
+ }))
+
+ const dropzone = createDropzone({
+ onDrop(files: File[]) {
+ if (!files[0]) {
+ throw new Error("No file was uploaded");
+ }
+
+ setStatusText("Uploading...");
+ setUploading(true);
+
+ updatePodcastImage.mutate({
+ body: {
+ image: files[0],
+ },
+ path: {
+ podcast_id: props.podcastId,
+ }
+ }, {
+ onSuccess() {
+ setStatusText("Podcast image uploaded!");
+ setUploading(false);
+ },
+ onError(error, _variables, _context) {
+ setStatusText(`Error uploading: ${error.detail}`);
+ setUploading(false);
+ },
+ });
+ }
+ });
+
+ return <>
+
+
+ {statusText()}
+
+
+
+
+
+
+
+ {dropzone.isDragActive ? "Drop podcast image here..." : "Drag podcast image here to upload, or click to open"}
+
+
+
+ >;
+}
diff --git a/client/src/components/upload.tsx b/client/src/components/upload.tsx
new file mode 100644
index 0000000..d38f272
--- /dev/null
+++ b/client/src/components/upload.tsx
@@ -0,0 +1,71 @@
+import { createDropzone } from "@soorria/solid-dropzone";
+import { UploadIcon } from "lucide-solid";
+import HugeUploader from "huge-uploader";
+import { SERVER_URL } from "../constants";
+import { createSignal, Show } from "solid-js";
+import { user } from "../auth";
+
+export default function Upload(props: { podcastId: string }) {
+ var uploader: any = null;
+ const [uploading, setUploading] = createSignal(false);
+ const [statusText, setStatusText] = createSignal(null);
+ const [uploadProgress, setUploadProgress] = createSignal(null);
+
+ const dropzone = createDropzone({
+ onDrop(files: File[]) {
+ if (!files[0]) {
+ throw new Error("No file was uploaded");
+ }
+
+ setUploading(true);
+ setStatusText("Uploading...");
+ setUploadProgress(0);
+
+ uploader = new HugeUploader({
+ endpoint: new URL(`/api/podcasts/${props.podcastId}/episodes`, SERVER_URL).href,
+ file: files[0],
+ headers: {
+ name: encodeURIComponent(files[0].name),
+ Authorization: `Bearer ${user()?.access_token}`
+ },
+ });
+
+ uploader.on("error", (err: any) => {
+ console.error(err);
+ setUploading(false);
+ setStatusText(`Something went wrong: ${err.detail}`);
+ });
+
+ uploader.on("progress", (progress: { detail: number }) => {
+ if (progress.detail == 100) return;
+ setUploadProgress(progress.detail);
+ setStatusText(`Uploading (${progress.detail.toFixed(0)}%)...`);
+ });
+
+ uploader.on("finish", (body: any) => {
+ console.log("File uploaded", body);
+ setUploadProgress(100);
+ setUploading(false);
+ setStatusText("Upload complete! The episode will be processed in the background. This may take a few minutes but it's safe to navigate away.");
+ });
+ }
+ });
+
+ return <>
+
+
+ {uploadProgress() &&
{uploadProgress()}% }
+ {statusText()}
+
+
+
+
+
+
+
+ {dropzone.isDragActive ? "Drop episode here..." : "Drag episode here to upload, or click to open"}
+
+
+
+ >;
+}
diff --git a/client/src/constants.ts b/client/src/constants.ts
new file mode 100644
index 0000000..58698f9
--- /dev/null
+++ b/client/src/constants.ts
@@ -0,0 +1 @@
+export const SERVER_URL = import.meta.env.PROD ? "" : "http://localhost:8000";
diff --git a/client/src/helpers.ts b/client/src/helpers.ts
new file mode 100644
index 0000000..719b53e
--- /dev/null
+++ b/client/src/helpers.ts
@@ -0,0 +1,21 @@
+export function humanFileSize(bytes: number, si=false, dp=1) {
+ const thresh = si ? 1000 : 1024;
+
+ if (Math.abs(bytes) < thresh) {
+ return bytes + ' B';
+ }
+
+ const units = si
+ ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+ : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+ let u = -1;
+ const r = 10**dp;
+
+ do {
+ bytes /= thresh;
+ ++u;
+ } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
+
+
+ return bytes.toFixed(dp) + ' ' + units[u];
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..6119ad9
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,68 @@
+:root {
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ place-items: center;
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/client/src/index.tsx b/client/src/index.tsx
new file mode 100644
index 0000000..cf90e6e
--- /dev/null
+++ b/client/src/index.tsx
@@ -0,0 +1,89 @@
+/* @refresh reload */
+import { render } from 'solid-js/web'
+import { Navigate, Router } from '@solidjs/router'
+import { lazy } from 'solid-js';
+import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
+import { client as apiClient } from './client/sdk.gen';
+
+import "bulma/css/bulma.min.css";
+import { SERVER_URL } from './constants';
+import Protected from './components/Protected';
+import AuthProvider from './components/AuthProvider';
+import { refreshToken, user } from './auth';
+
+const wrapper = document.getElementById('root');
+
+if (!wrapper) {
+ throw new Error("Wrapper div not found");
+}
+
+const client = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 1000 * 60, // 1 minute
+ suspense: true,
+ throwOnError(error: any, _query) {
+ if (error instanceof Object && error.hasOwnProperty("detail") && error["detail"].toLowerCase().includes("not found")) {
+ throw new Error("Not found");
+ }
+ return true;
+ },
+ }
+ }
+});
+
+apiClient.setConfig({
+ baseUrl: SERVER_URL
+});
+
+apiClient.interceptors.request.use((request, _options) => {
+ const u = user();
+ if (u !== null) {
+ request.headers.set("Authorization", `Bearer ${u.access_token}`);
+ }
+ return request
+});
+
+apiClient.interceptors.response.use(async (response, _request, _options) => {
+ if (response.status === 401 && user()) {
+ console.warn("Access token expired, trying refresh...");
+ await refreshToken();
+ }
+
+ return response;
+});
+
+const routes = [
+ {
+ path: "/",
+ component: () => ,
+ },
+ {
+ path: "/callback",
+ component: lazy(() => import("./routes/Callback")),
+ },
+ {
+ path: "/login",
+ component: lazy(() => import("./routes/Login")),
+ },
+ {
+ path: "/admin",
+ component: () => (
+ {lazy(() => import("./routes/admin/podcasts"))()}
+ ),
+ },
+ {
+ path: "/admin/:podcastId",
+ component: () => (
+ {lazy(() => import("./routes/admin/podcast"))()}
+ ),
+ },
+ {
+ path: "/admin/:podcastId/:episodeId",
+ component: () => (
+ {lazy(() => import("./routes/admin/episode"))()}
+ ),
+ }
+];
+
+render(() => {routes} , wrapper!)
diff --git a/client/src/routes/Callback.tsx b/client/src/routes/Callback.tsx
new file mode 100644
index 0000000..c387875
--- /dev/null
+++ b/client/src/routes/Callback.tsx
@@ -0,0 +1,17 @@
+import { useNavigate } from "@solidjs/router"
+import { onMount } from "solid-js";
+import { fetchUser, getUserManager } from "../auth";
+
+const Callback = () => {
+ const navigate = useNavigate();
+
+ onMount(async () => {
+ await (await getUserManager()).signinRedirectCallback();
+ await fetchUser();
+ navigate("/admin");
+ });
+
+ return Signing in...
;
+}
+
+export default Callback;
diff --git a/client/src/routes/Login.tsx b/client/src/routes/Login.tsx
new file mode 100644
index 0000000..41ac5e9
--- /dev/null
+++ b/client/src/routes/Login.tsx
@@ -0,0 +1,16 @@
+import { login } from "../auth"
+
+const Login = () => {
+ return (
+
+
+
+
Login
+ Sign in with OIDC
+
+
+
+ );
+}
+
+export default Login;
diff --git a/client/src/routes/admin/episode.tsx b/client/src/routes/admin/episode.tsx
new file mode 100644
index 0000000..f8c6579
--- /dev/null
+++ b/client/src/routes/admin/episode.tsx
@@ -0,0 +1,173 @@
+import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
+import AdminLayout from "../../admin-layout";
+import { createEffect, createSignal, ErrorBoundary, Suspense } from "solid-js";
+import { A, useNavigate, useParams } from "@solidjs/router";
+import { deleteEpisodeMutation, readEpisodeOptions, readEpisodeQueryKey, readEpisodesQueryKey, updateEpisodeMutation } from "../../client/@tanstack/solid-query.gen";
+import Error from "../../components/error";
+import Loading from "../../components/loading";
+import { Save, Trash } from "lucide-solid";
+import { SERVER_URL } from "../../constants";
+import { PodcastEpisodePublic } from "../../client";
+import { humanFileSize } from "../../helpers";
+import UploadEpisodeAdditional from "../../components/UploadEpisodeAdditional";
+
+function convertDateToInput(d: Date): string {
+ const year = d.getFullYear();
+ const month = (d.getMonth() + 1).toString().padStart(2, "0");
+ const day = d.getDate().toString().padStart(2, "0");
+ const hours = d.getHours().toString().padStart(2, "0");
+ const minutes = d.getMinutes().toString().padStart(2, "0");
+
+ return `${year}-${month}-${day}T${hours}:${minutes}`;
+}
+
+export default function AdminEpisode() {
+ const params = useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const episodeQuery = createQuery(() => ({
+ ...readEpisodeOptions({
+ path: {
+ podcast_id: params.podcastId,
+ episode_id: params.episodeId,
+ }
+ }),
+ }));
+ const updateEpisode = createMutation(() => ({
+ ...updateEpisodeMutation(),
+ onSuccess(data, vars) {
+ queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
+ queryClient.setQueryData(readEpisodeQueryKey({ path: vars.path }), data);
+ }
+ }));
+ const deleteEpisode = createMutation(() => ({
+ ...deleteEpisodeMutation(),
+ onSuccess(_, vars) {
+ queryClient.invalidateQueries({ queryKey: readEpisodeQueryKey({ path: vars.path }) });
+ queryClient.setQueryData(readEpisodesQueryKey({ path: vars.path }), (oldData: PodcastEpisodePublic[]) => oldData.filter((x) => x.id !== vars.path.episode_id));
+ }
+ }));
+
+ const [name, setName] = createSignal("");
+ const [description, setDescription] = createSignal("");
+ const [publishDate, setPublishDate] = createSignal("");
+
+ createEffect(() => {
+ if (episodeQuery.isSuccess && episodeQuery.data) {
+ setName(episodeQuery.data.name);
+ setDescription(episodeQuery.data.description || "");
+ const date = episodeQuery.data.publish_date ? new Date(episodeQuery.data.publish_date) : new Date();
+ setPublishDate(convertDateToInput(date));
+ }
+ });
+
+ const deleteAction = () => {
+ if (confirm("Are you sure you want to delete the podcast?")) {
+ deleteEpisode.mutate({
+ path: {
+ podcast_id: params.podcastId,
+ episode_id: params.episodeId,
+ }
+ });
+ navigate(`/admin/${params.podcastId}`);
+ }
+ }
+
+ const saveChanges = () => {
+ updateEpisode.mutate({
+ body: {
+ name: name(),
+ description: description(),
+ publish_date: publishDate()
+ },
+ path: {
+ episode_id: params.episodeId,
+ podcast_id: params.podcastId
+ }
+ });
+ };
+
+ const audioUrl = () => new URL(params.podcastId + "/" + episodeQuery.data?.id + ".m4a", SERVER_URL).href;
+
+ return (
+
+ }>
+ }>
+
+
+
{episodeQuery.data?.name}
+
{episodeQuery.data?.publish_date && (new Date(episodeQuery.data?.publish_date)).toLocaleString()} • {episodeQuery.data?.duration && `${(episodeQuery.data.duration / 60).toFixed(0)} minutes` || "Unknown"}
+ {episodeQuery.data?.description_html &&
}
+
+
+
+
+
Update Episode
+
+ {updateEpisode.isSuccess &&
+ updateEpisode.reset()}>
+ This episode has been updated successfully.
+
}
+
+ {updateEpisode.isError &&
+ updateEpisode.reset()}>
+ Something went wrong. {JSON.stringify(updateEpisode.error)}
+
}
+
+
+
Name
+
+ setName(e.target.value)} />
+
+
+
+
+
Description
+
+
+
+
The description supports Markdown . Leave this empty to unset the description.
+
+
+
+
Publish Date
+
+ setPublishDate(e.target.value)} />
+
+
+
+
saveChanges()}> Save Changes
+
+
+
+
+
+
Additional Upload
+
Drag extra files here
+
+
+
+
+
+
+
Info
+
+ Audio URL: {audioUrl()}
+ SHA256 Checksum: {episodeQuery.data?.file_hash}
+ File Size: {episodeQuery.data?.file_size ? humanFileSize(episodeQuery.data?.file_size, true) : "?"}
+
+
+
+
+
+
+
Danger Zone
+ deleteAction()}> Delete
+
+
+
+
+
+ );
+}
diff --git a/client/src/routes/admin/podcast.tsx b/client/src/routes/admin/podcast.tsx
new file mode 100644
index 0000000..d2291d6
--- /dev/null
+++ b/client/src/routes/admin/podcast.tsx
@@ -0,0 +1,208 @@
+import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
+import AdminLayout from "../../admin-layout";
+import { createEffect, createSignal, ErrorBoundary, For, Match, Show, Suspense, Switch } from "solid-js";
+import { A, useNavigate, useParams } from "@solidjs/router";
+import { deletePodcastMutation, readEpisodesOptions, readPodcastOptions, readPodcastQueryKey, readPodcastsQueryKey, updatePodcastMutation } from "../../client/@tanstack/solid-query.gen";
+import Error from "../../components/error";
+import Loading from "../../components/loading";
+import { List, Pencil, RefreshCw, Save, Settings, Trash } from "lucide-solid";
+import { SERVER_URL } from "../../constants";
+import Upload from "../../components/upload";
+import UploadImage from "../../components/upload-image";
+import { PodcastPublic } from "../../client";
+
+export default function AdminPodcast() {
+ const params = useParams();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ const podcastQuery = createQuery(() => ({
+ ...readPodcastOptions({
+ path: {
+ podcast_id: params.podcastId
+ },
+ }),
+ }));
+ const episodeQuery = createQuery(() => ({
+ ...readEpisodesOptions({
+ path: {
+ podcast_id: params.podcastId
+ },
+ }),
+ }));
+ const updatePodcast = createMutation(() => ({
+ ...updatePodcastMutation(),
+ onSuccess(data, vars) {
+ queryClient.setQueryData(readPodcastQueryKey({ path: vars.path }), data);
+ queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData.filter((x) => x.id !== data.id), data] : oldData);
+ }
+ }));
+ const deletePodcast = createMutation(() => ({
+ ...deletePodcastMutation(),
+ onSuccess(_, vars) {
+ queryClient.invalidateQueries({ queryKey: readPodcastQueryKey({ path: vars.path }) });
+ queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? oldData.filter((x) => x.id !== vars.path.podcast_id) : oldData);
+ }
+ }));
+
+ const [tab, setTab] = createSignal(0);
+ const [name, setName] = createSignal("");
+ const [description, setDescription] = createSignal("");
+
+ createEffect(() => {
+ if (podcastQuery.isSuccess && podcastQuery.data) {
+ setName(podcastQuery.data.name);
+ setDescription(podcastQuery.data.description);
+ }
+ })
+
+ const deleteAction = () => {
+ if (confirm("Are you sure you want to delete the podcast?")) {
+ deletePodcast.mutate({
+ path: {
+ podcast_id: params.podcastId,
+ }
+ });
+ navigate("/admin");
+ }
+ }
+
+ const saveChanges = () => {
+ updatePodcast.mutate({
+ body: {
+ name: name(),
+ description: description(),
+ },
+ path: {
+ podcast_id: params.podcastId
+ }
+ });
+ }
+
+ return (
+
+ }>
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
{podcastQuery.data?.name}
+
{podcastQuery.data?.description}
+
+
+
+
0}>
+ Subscribe at:
+ {new URL(`${params.podcastId}.xml`, SERVER_URL).href}
+
+
+
+
+
+
+
+
+
+
+
Published Episodes
+
+ episodeQuery.refetch()}> Refresh
+
+
+
+
+
+ Name
+ Published
+ Duration
+ Actions
+
+
+
+
+ {(episode) => (
+
+ {episode.name}
+ {episode.publish_date ? (new Date(episode.publish_date)).toLocaleString() : "?"}
+ {episode.duration ? `${(episode.duration / 60).toFixed(0)}min` : "?"}
+
+ Edit
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {updatePodcast.isSuccess &&
+ updatePodcast.reset()}>
+ This episode has been updated successfully.
+
}
+
+ {updatePodcast.isError &&
+ updatePodcast.reset()}>
+ Something went wrong. {JSON.stringify(updatePodcast.error)}
+
}
+
+
+
Name
+
+ setName(e.target.value)} />
+
+
+
+
+
+
saveChanges()}> Save Changes
+
+
+
+
+
+
+
+
Danger Zone
+ deleteAction()}> Delete
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/routes/admin/podcasts.tsx b/client/src/routes/admin/podcasts.tsx
new file mode 100644
index 0000000..c4f2425
--- /dev/null
+++ b/client/src/routes/admin/podcasts.tsx
@@ -0,0 +1,80 @@
+import { createMutation, createQuery, useQueryClient, } from "@tanstack/solid-query";
+import AdminLayout from "../../admin-layout";
+import { ErrorBoundary, For, Show, Suspense } from "solid-js";
+import { A } from "@solidjs/router";
+import { createPodcastMutation, readPodcastsOptions, readPodcastsQueryKey } from "../../client/@tanstack/solid-query.gen";
+import Loading from "../../components/loading";
+import Error from "../../components/error";
+import { SERVER_URL } from "../../constants";
+import { Eye, Plus } from "lucide-solid";
+import { PodcastPublic } from "../../client";
+
+export default function AdminPodcasts() {
+ const queryClient = useQueryClient();
+
+ const query = createQuery(() => ({
+ ...readPodcastsOptions(),
+ }));
+
+ const createPodcast = createMutation(() => ({
+ ...createPodcastMutation(),
+ onSuccess(data) {
+ queryClient.setQueryData(readPodcastsQueryKey(), (oldData: PodcastPublic[]) => oldData ? [...oldData, data] : oldData);
+ },
+ }));
+
+ const createAction = () => {
+ const name = prompt("Enter a podcast name");
+
+ if (name != null && name.trim().length > 0) {
+ createPodcast.mutate({
+ body: {
+ name,
+ description: name
+ }
+ });
+ }
+ }
+
+ return (
+
+ }>
+ }>
+
+
+
Podcasts
+
+
createAction()}>
+
+ Create
+
+
+
+ {(podcast) => (
+
+
+
+
+
+
+
+
+
+
+
+
{podcast.name}
+
{podcast.description}
+
View
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/types/huge-uploader.d.ts b/client/src/types/huge-uploader.d.ts
new file mode 100644
index 0000000..f416ce2
--- /dev/null
+++ b/client/src/types/huge-uploader.d.ts
@@ -0,0 +1,48 @@
+import { EventTarget } from "event-target-shim";
+
+interface HugeUploaderEventMap {
+ progress: CustomEvent<{ detail: number }>;
+ finish: CustomEvent<{ detail: string }>;
+ error: CustomEvent<{ detail: string | Response }>;
+ fileRetry: CustomEvent<{ detail: { message: string; chunk: number; retriesLeft: number } }>;
+ online: Event;
+ offline: Event;
+}
+
+interface HugeUploaderParams {
+ endpoint: string;
+ file: File;
+ headers?: Record;
+ postParams?: Record;
+ chunkSize?: number;
+ retries?: number;
+ delayBeforeRetry?: number;
+}
+
+class HugeUploader {
+ private endpoint: string;
+ private file: File;
+ private headers: Record;
+ private postParams?: Record;
+ private chunkSize: number;
+ private retries: number;
+ private delayBeforeRetry: number;
+ private start: number;
+ private chunk: Blob | null;
+ private chunkCount: number;
+ private totalChunks: number;
+ private retriesCount: number;
+ private offline: boolean;
+ private paused: boolean;
+ private _reader: FileReader;
+ private _eventTarget: EventTarget;
+
+ constructor(params: HugeUploaderParams);
+
+ on(eventType: T, listener: (event: HugeUploaderEventMap[T]) => void): void;
+ togglePause(): void;
+}
+
+declare module "huge-uploader" {
+ export default HugeUploader
+}
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/client/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json
new file mode 100644
index 0000000..12f076e
--- /dev/null
+++ b/client/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": "./",
+ "paths": {
+ "*": ["src/types/*"]
+ }
+ },
+ "include": ["src", "src/types"]
+}
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json
new file mode 100644
index 0000000..db0becc
--- /dev/null
+++ b/client/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/client/vite.config.ts b/client/vite.config.ts
new file mode 100644
index 0000000..a92bb6e
--- /dev/null
+++ b/client/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vite'
+import solid from 'vite-plugin-solid'
+
+export default defineConfig({
+ plugins: [solid()],
+ build: {
+ assetsDir: "static",
+ }
+})
diff --git a/migrations/README b/migrations/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/migrations/env.py b/migrations/env.py
new file mode 100644
index 0000000..778f3b6
--- /dev/null
+++ b/migrations/env.py
@@ -0,0 +1,77 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from sqlmodel import SQLModel
+
+from models import * # noqa: F403
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = SQLModel.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/migrations/script.py.mako b/migrations/script.py.mako
new file mode 100644
index 0000000..6ce3351
--- /dev/null
+++ b/migrations/script.py.mako
@@ -0,0 +1,27 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/migrations/versions/9efcecc1e58d_initial_migration.py b/migrations/versions/9efcecc1e58d_initial_migration.py
new file mode 100644
index 0000000..5ff3b33
--- /dev/null
+++ b/migrations/versions/9efcecc1e58d_initial_migration.py
@@ -0,0 +1,57 @@
+"""initial migration
+
+Revision ID: 9efcecc1e58d
+Revises:
+Create Date: 2025-02-03 18:45:42.630841
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+import sqlmodel
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "9efcecc1e58d"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "podcast",
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("explicit", sa.Boolean(), nullable=False),
+ sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("owner_id", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column("image_filename", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_table(
+ "podcastepisode",
+ sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("duration", sa.Float(), nullable=True),
+ sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
+ sa.Column("file_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("file_size", sa.Integer(), nullable=False),
+ sa.Column("publish_date", sa.DateTime(), nullable=False),
+ sa.Column("id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.Column("podcast_id", sqlmodel.sql.sqltypes.AutoString(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["podcast_id"],
+ ["podcast.id"],
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table("podcastepisode")
+ op.drop_table("podcast")
+ # ### end Alembic commands ###
diff --git a/pyproject.toml b/pyproject.toml
index 0068746..d441468 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,16 +6,23 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"aiofiles>=24.1.0",
+ "alembic>=1.14.1",
+ "bleach>=6.2.0",
+ "cryptography>=44.0.0",
"fastapi[standard]>=0.115.6",
"ffmpeg-normalize>=1.31.0",
"ffmpeg-python>=0.2.0",
+ "itsdangerous>=2.2.0",
"markdown>=3.7",
"nanoid>=2.0.0",
"pillow>=11.1.0",
"podgen>=1.1.0",
"pydantic>=2.10.5",
"pydantic-settings>=2.7.1",
+ "pyjwt>=2.10.1",
"python-multipart>=0.0.20",
+ "requests>=2.32.3",
"sqlmodel>=0.0.22",
"structlog>=24.4.0",
+ "uvicorn>=0.34.0",
]
diff --git a/scripts/import-episode.py b/scripts/import-episode.py
deleted file mode 100644
index 1664dc7..0000000
--- a/scripts/import-episode.py
+++ /dev/null
@@ -1,90 +0,0 @@
-import argparse
-import hashlib
-import shutil
-import sys
-from pathlib import Path
-
-import ffmpeg
-import structlog
-from sqlmodel import Session, select
-
-# Add the src directory to the system path
-sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
-
-import models as models
-from settings import settings
-
-log = structlog.get_logger()
-
-
-def import_episode(filename: Path, podcast_id: str, process: bool, move: bool = True):
- if process:
- raise NotImplementedError("Importing with processing is not implemented")
-
- if filename.suffix != ".m4a" and not process:
- log.error("Input file must be in an m4a container if not processing")
- return
-
- with Session(models.engine) as session:
- podcast = session.exec(
- select(models.Podcast).where(models.Podcast.id == podcast_id)
- ).first()
-
- if podcast is None:
- log.error("Failed importing episode, podcast does not exist.")
- return
-
- episode = models.PodcastEpisode(
- name=filename.stem, file_size=0, file_hash="", podcast_id=podcast.id
- )
-
- episode_filename = settings.directory / f"{episode.id}.m4a"
-
- if move:
- log.info("Moving episode to %s...", episode_filename)
- shutil.move(filename, episode_filename)
- else:
- log.info("Copying episode to %s...", episode_filename)
- shutil.copyfile(filename, episode_filename)
-
- probe = ffmpeg.probe(str(episode_filename))
- stream = next(
- (stream for stream in probe["streams"] if stream["codec_type"] == "audio"),
- None,
- )
-
- file_hash = hashlib.sha256()
- with open(episode_filename, "rb") as f:
- for byte_block in iter(lambda: f.read(4096), b""):
- file_hash.update(byte_block)
-
- episode.duration = (
- float(stream["duration"])
- if stream is not None and "duration" in stream
- else None
- )
- episode.file_hash = file_hash.hexdigest()
- episode.file_size = episode_filename.stat().st_size
-
- session.add(episode)
- session.commit()
-
- log.info("Imported episode as %s", episode.id)
-
-
-def main():
- parser = argparse.ArgumentParser(
- prog="import-episode.py",
- description="Import an episode",
- )
- parser.add_argument("filename")
- parser.add_argument("podcast_id")
- parser.add_argument("--process", action="store_true")
-
- args = parser.parse_args()
-
- import_episode(Path(args.filename), args.podcast_id, args.process)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/update-pub-date.py b/scripts/update-pub-date.py
deleted file mode 100644
index d8c18d2..0000000
--- a/scripts/update-pub-date.py
+++ /dev/null
@@ -1,50 +0,0 @@
-import argparse
-import sys
-from datetime import datetime
-from pathlib import Path
-
-import structlog
-from sqlmodel import Session, select
-
-# Add the src directory to the system path
-sys.path.append(str(Path(__file__).resolve().parent.parent / "src"))
-
-import models as models
-
-log = structlog.get_logger()
-
-
-def update_pub_date(episode_id: str, new_date: str):
- with Session(models.engine) as session:
- episode = session.exec(
- select(models.PodcastEpisode).where(models.PodcastEpisode.id == episode_id)
- ).first()
-
- if episode is None:
- log.error("Could not find episode")
- return
-
- episode.publish_date = datetime.fromisoformat(new_date)
- assert episode.publish_date.tzinfo is not None, "timezone is required"
-
- session.add(episode)
- session.commit()
-
- log.info("Updated episode", episode.id)
-
-
-def main():
- parser = argparse.ArgumentParser(
- prog="update-pub-date.py",
- description="Update an episode publish date",
- )
- parser.add_argument("episode_id")
- parser.add_argument("new_date")
-
- args = parser.parse_args()
-
- update_pub_date(args.episode_id, args.new_date)
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/auth.py b/src/auth.py
new file mode 100644
index 0000000..40c5590
--- /dev/null
+++ b/src/auth.py
@@ -0,0 +1,57 @@
+from typing import Annotated, Any, Dict
+
+import jwt
+import jwt.algorithms
+from fastapi import HTTPException, Security, security
+from pydantic import BaseModel, Field
+
+from settings import settings
+
+jwks_client = jwt.PyJWKClient(
+ settings.oidc_jwks_url, headers={"User-Agent": "PodcastGenerator"}
+)
+
+oauth2_scheme = security.OAuth2AuthorizationCodeBearer(
+ authorizationUrl=settings.oidc_authorize_url, tokenUrl=settings.oidc_token_url
+)
+
+
+class CurrentUser(BaseModel):
+ user_id: str = Field()
+ user_name: str = Field()
+
+
+def user_token(
+ token_str: Annotated[str, Security(oauth2_scheme)],
+ required_scopes: security.SecurityScopes,
+) -> CurrentUser:
+ try:
+ token: Dict[Any, Any] = jwt.decode(
+ token_str,
+ jwks_client.get_signing_key_from_jwt(token_str).key,
+ algorithms=["RS256"],
+ audience=settings.oidc_permitted_jwt_audiences,
+ )
+ except Exception as e:
+ print(e)
+ raise HTTPException(
+ status_code=401,
+ detail="Could not validate credentials",
+ headers={"WWW-Authenticate": "Bearer"},
+ ) from e
+
+ for scope in required_scopes.scopes:
+ if scope not in token["scope"]:
+ raise HTTPException(
+ status_code=401,
+ detail="Not enough permissions",
+ headers={
+ "WWW-Authenticate",
+ f'Bearer scope="{required_scopes.scope_str}"',
+ },
+ )
+
+ return CurrentUser(
+ user_id=token.get(settings.oidc_sub_jwt_attribute),
+ user_name=token.get(settings.oidc_name_jwt_attribute),
+ )
diff --git a/src/episode_file.py b/src/episode_file.py
new file mode 100644
index 0000000..2c0825d
--- /dev/null
+++ b/src/episode_file.py
@@ -0,0 +1,76 @@
+import xml.etree.ElementTree as ET
+from datetime import timedelta
+from typing import Callable, List, Optional, Tuple
+
+from fastapi import HTTPException, UploadFile
+
+from models import PodcastEpisode
+
+
+async def djuced_track_list(
+ episode: PodcastEpisode, file: UploadFile
+) -> Optional[PodcastEpisode]:
+ root = ET.fromstring(await file.read())
+
+ # if this doesn't look like a djuced track list xml, return nothing
+ if root.tag != "recordEvents":
+ return None
+
+ tracks = []
+
+ for track in root.iter("track"):
+ title = track.get("song")
+ artist = track.get("artist")
+
+ intervals = track.findall("interval")
+
+ # if the title looks like it contains the artist too, overwrite the existing artist
+ title_segments = title.split(" - ")
+ if len(title_segments) == 2:
+ artist, title = title_segments
+
+ for interval in intervals:
+ tracks.append((float(interval.get("start")), title, artist))
+
+ # sort by start time
+ tracks = sorted(tracks, key=lambda x: x[0], reverse=False)
+
+ # update description
+ track_list_str = ""
+
+ for i, (t, title, artist) in enumerate(tracks):
+ time = timedelta(seconds=round(t))
+ track_list_str += f"{i + 1}. {title} _- {artist} [{time}]_\n"
+
+ episode.description += "\n\n**Track list**\n\n" + track_list_str
+ return episode
+
+
+# list of file processors
+# these are processed in order and only run if the file content type matches the first tuple string
+# the second tuple item is the function to run which should return none if the file was not able to be processed, otherwise a mutated episode object
+processors: List[
+ Tuple[str, Callable[[PodcastEpisode, UploadFile], Optional[PodcastEpisode]]]
+] = [("text/xml", djuced_track_list)]
+
+
+async def process_additional_episode_upload(
+ episode: PodcastEpisode, file: UploadFile
+) -> PodcastEpisode:
+ for processor_content_type, do_process in processors:
+ if processor_content_type != file.content_type:
+ continue
+
+ try:
+ result = await do_process(episode, file)
+
+ if result is not None:
+ return result
+ except Exception as e:
+ print(f"Failed to process using {do_process.__name__}: {e}")
+ continue
+
+ raise HTTPException(
+ status_code=400,
+ detail=f"Unable to process additional episode upload ({file.content_type})",
+ )
diff --git a/src/helpers.py b/src/helpers.py
new file mode 100644
index 0000000..dab7db2
--- /dev/null
+++ b/src/helpers.py
@@ -0,0 +1,15 @@
+from typing import Optional
+
+import bleach
+import markdown
+
+
+def render_markdown(content: Optional[str]) -> Optional[str]:
+ if content is None or content.strip() == "":
+ return None
+
+ return bleach.clean(
+ markdown.markdown(content),
+ tags=["p", "a", "code", "pre", "blockquote", "ol", "li", "ul", "strong", "em"],
+ attributes={"a": ["href"]},
+ )
diff --git a/src/main.py b/src/main.py
index fab5122..3f4cc57 100644
--- a/src/main.py
+++ b/src/main.py
@@ -1,29 +1,26 @@
import urllib.parse
import uuid
-from contextlib import asynccontextmanager
from datetime import timedelta, timezone
from pathlib import Path
from typing import Annotated, Any, Generator, Optional
-import markdown
import podgen
import structlog
-from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
+from fastapi import Depends, FastAPI, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
-from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
+from fastapi.responses import FileResponse
+from fastapi.routing import APIRoute
+from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image
-from sqlmodel import Session, and_, or_, select
+from sqlmodel import Session, and_, desc, select
import models
+from auth import CurrentUser, user_token
+from episode_file import process_additional_episode_upload
+from helpers import render_markdown
from process import AudioProcessor
-from settings import settings
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
- models.setup_db(models.engine)
- yield
+from settings import AppConfig, settings
def get_session() -> Generator[Session, Any, None]:
@@ -31,26 +28,26 @@ def get_session() -> Generator[Session, Any, None]:
yield session
-def handle_user_auth(request: Request) -> tuple[str, str]:
- if (
- settings.forward_auth_name_header is None
- or settings.forward_auth_uid_header is None
- ):
- return ("default", "Admin")
-
- return (
- request.headers.get(settings.forward_auth_uid_header, "default"),
- request.headers.get(settings.forward_auth_name_header, "Admin"),
- )
+def use_route_names_as_operation_ids(app: FastAPI) -> None:
+ for route in app.routes:
+ if isinstance(route, APIRoute):
+ route.operation_id = route.name
SessionDep = Annotated[Session, Depends(get_session)]
-AuthDep = Annotated[tuple[str, str], Depends(handle_user_auth)]
+AuthDep = Annotated[CurrentUser, Depends(user_token)]
log = structlog.get_logger()
-app = FastAPI(lifespan=lifespan)
+app = FastAPI(
+ title="Podcast Generator",
+ swagger_ui_init_oauth={
+ "appName": "PodcastGenerator",
+ "clientId": settings.oidc_client_id,
+ "usePkceWithAuthorizationCodeGrant": True,
+ },
+)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
@@ -59,103 +56,137 @@ templates = Jinja2Templates(directory="src/templates")
audio_processor = AudioProcessor()
audio_processor.start_processing()
+app.mount("/static", StaticFiles(directory="dist/static"), name="static")
-@app.get("/admin")
-def admin_list_podcasts(session: SessionDep, request: Request, user: AuthDep):
+
+@app.get("/api/config", response_model=AppConfig)
+def get_app_config():
+ return AppConfig(
+ oidc_authority=settings.oidc_authority,
+ oidc_client_id=settings.oidc_client_id,
+ oidc_scopes=settings.oidc_scopes,
+ )
+
+
+@app.get("/api/user", response_model=CurrentUser)
+def read_user(auth: AuthDep):
+ return auth
+
+
+@app.post("/api/podcasts", response_model=models.PodcastPublic)
+def create_podcast(auth: AuthDep, session: SessionDep, podcast: models.PodcastCreate):
+ db_podcast = models.Podcast.model_validate(podcast)
+ db_podcast.owner_id = auth.user_id
+ session.add(db_podcast)
+ session.commit()
+ session.refresh(db_podcast)
+ return db_podcast
+
+
+@app.get("/api/podcasts", response_model=list[models.PodcastPublic])
+def read_podcasts(auth: AuthDep, session: SessionDep):
podcasts = session.exec(
- select(models.Podcast).where(
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- )
- )
+ select(models.Podcast).where(models.Podcast.owner_id == auth.user_id)
).all()
-
- return templates.TemplateResponse(
- request=request,
- name="admin_feeds.html.j2",
- context={
- "podcasts": podcasts,
- "user_name": user[1],
- },
- )
+ return podcasts
-@app.get("/admin/new")
-def admin_create_podcast(request: Request):
- return templates.TemplateResponse(
- request=request,
- name="admin_new.html.j2",
- )
+@app.get("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
+def read_podcast(session: SessionDep, podcast_id: str):
+ podcast = session.get(models.Podcast, podcast_id)
+ if not podcast:
+ raise HTTPException(status_code=404, detail="Podcast not found")
+ return podcast
-@app.post("/admin/new")
-def admin_create_podcast_post(
+@app.patch("/api/podcasts/{podcast_id}", response_model=models.PodcastPublic)
+def update_podcast(
+ auth: AuthDep,
session: SessionDep,
- request: Request,
- user: AuthDep,
- name: Annotated[str, Form()],
+ podcast_id: str,
+ podcast: models.PodcastUpdate = None,
):
- if name.strip() == "":
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=400,
- context={
- "title": "Invalid entry",
- "description": "Name must not be empty",
- },
+ db_podcast = session.get(models.Podcast, podcast_id)
+ if not db_podcast:
+ raise HTTPException(status_code=404, detail="Podcast not found")
+
+ if db_podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to update this podcast"
)
- podcast = models.Podcast(name=name, description=name, owner_id=user[0])
+ podcast_data = podcast.model_dump(exclude_unset=True)
+ db_podcast.sqlmodel_update(podcast_data)
+
+ session.add(db_podcast)
+ session.commit()
+ session.refresh(db_podcast)
+ return db_podcast
+
+
+@app.post("/api/podcasts/{podcast_id}/image", response_model=models.PodcastPublic)
+def update_podcast_image(
+ auth: AuthDep,
+ session: SessionDep,
+ podcast_id: str,
+ image: UploadFile,
+):
+ podcast = session.get(models.Podcast, podcast_id)
+ if not podcast:
+ raise HTTPException(status_code=404, detail="Podcast not found")
+
+ if podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to update this podcast"
+ )
+
+ if image is not None and image.size > 0:
+ if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
+ return HTTPException(
+ 400, detail="The uploaded podcast image must be a jpg or png"
+ )
+
+ im = Image.open(image.file)
+
+ if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
+ return HTTPException(
+ 400,
+ detail="The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size",
+ )
+
+ if im.mode != "RGB":
+ return HTTPException(
+ 400, detail="The uploaded podcast image must be in RGB format"
+ )
+
+ if podcast.image_filename is not None:
+ (settings.directory / podcast.image_filename).unlink(missing_ok=True)
+
+ filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix
+ im.save(settings.directory / filename)
+ podcast.image_filename = filename
session.add(podcast)
session.commit()
-
- return RedirectResponse(f"/admin/{podcast.id}", status_code=303)
+ session.refresh(podcast)
+ return podcast
-@app.get("/admin/{podcast_id}")
-def admin_list_podcast(
- session: SessionDep, request: Request, podcast_id: str, user: AuthDep
-):
- podcast = session.exec(
- select(models.Podcast).where(
- and_(
- models.Podcast.id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
- ).first()
+@app.delete("/api/podcasts/{podcast_id}")
+def delete_podcast(auth: AuthDep, session: SessionDep, podcast_id: str):
+ podcast = session.get(models.Podcast, podcast_id)
- if podcast is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=400,
- context={
- "title": "Not found",
- "description": "The podcast was not found.",
- },
+ if not podcast:
+ raise HTTPException(status_code=404, detail="Podcast not found")
+
+ if podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to delete this podcast"
)
- episodes = podcast.episodes
- episodes.sort(key=lambda e: e.publish_date, reverse=True)
-
- return templates.TemplateResponse(
- request=request,
- name="admin_feed.html.j2",
- context={
- "podcast": podcast,
- "episodes": episodes,
- "feed_uri": urllib.parse.urljoin(
- str(request.base_url), f"{podcast.id}.xml"
- ),
- },
- )
+ session.delete(podcast)
+ session.commit()
+ return {"ok": True}
def finish_processing(
@@ -185,13 +216,13 @@ def finish_processing(
session.commit()
-@app.post("/admin/{podcast_id}/upload")
+@app.post("/api/podcasts/{podcast_id}/episodes")
async def admin_upload_episode(
+ auth: AuthDep,
session: SessionDep,
request: Request,
podcast_id: str,
file: UploadFile,
- user: AuthDep,
):
file_id = request.headers.get("uploader-file-id")
chunks_total = int(request.headers.get("uploader-chunks-total"))
@@ -204,20 +235,18 @@ async def admin_upload_episode(
file_id = "".join(c for c in file_id if c.isalnum()).strip()
podcast = session.exec(
- select(models.Podcast).where(
- and_(
- models.Podcast.id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
+ select(models.Podcast).where(models.Podcast.id == podcast_id)
).first()
if podcast is None:
raise HTTPException(status_code=404, detail="Podcast not found")
+ if podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403,
+ detail="You do not have permission to add episodes to this podcast",
+ )
+
is_last = (chunk_number + 1) == chunks_total
file_name = f"{file_id}_{chunk_number}"
@@ -253,256 +282,137 @@ async def admin_upload_episode(
),
)
- return JSONResponse({"message": "File Uploaded"}, status_code=200)
+ return {"message": "File Uploaded"}
- return JSONResponse({"message": "Chunk Uploaded"}, status_code=200)
+ return {"message": "Chunk Uploaded"}
-@app.get("/admin/{podcast_id}/{episode_id}/delete")
-def admin_delete_episode(
+@app.get(
+ "/api/podcasts/{podcast_id}/episodes",
+ response_model=list[models.PodcastEpisodePublic],
+)
+def read_episodes(session: SessionDep, podcast_id: str):
+ episodes = session.exec(
+ select(models.PodcastEpisode)
+ .where(models.PodcastEpisode.podcast_id == podcast_id)
+ .order_by(desc(models.PodcastEpisode.publish_date))
+ ).all()
+ return episodes
+
+
+@app.get(
+ "/api/podcasts/{podcast_id}/episodes/{episode_id}",
+ response_model=models.PodcastEpisodePublic,
+)
+def read_episode(session: SessionDep, podcast_id: str, episode_id: str):
+ episode = session.exec(
+ select(models.PodcastEpisode).where(
+ and_(
+ models.PodcastEpisode.id == episode_id,
+ models.PodcastEpisode.podcast_id == podcast_id,
+ )
+ )
+ ).first()
+ if not episode:
+ raise HTTPException(status_code=404, detail="Episode not found")
+ return episode
+
+
+@app.patch(
+ "/api/podcasts/{podcast_id}/episodes/{episode_id}",
+ response_model=models.PodcastEpisodePublic,
+)
+def update_episode(
+ auth: AuthDep,
session: SessionDep,
- request: Request,
podcast_id: str,
episode_id: str,
- user: AuthDep,
+ episode: models.PodcastEpisodeUpdate,
+):
+ db_episode = session.exec(
+ select(models.PodcastEpisode).where(
+ and_(
+ models.PodcastEpisode.id == episode_id,
+ models.PodcastEpisode.podcast_id == podcast_id,
+ )
+ )
+ ).first()
+
+ if not db_episode:
+ raise HTTPException(status_code=404, detail="Episode not found")
+
+ if db_episode.podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to update this episode"
+ )
+
+ episode_data = episode.model_dump(exclude_unset=True)
+ db_episode.sqlmodel_update(episode_data)
+ session.add(db_episode)
+ session.commit()
+ session.refresh(db_episode)
+ return db_episode
+
+
+@app.post(
+ "/api/podcasts/{podcast_id}/episodes/{episode_id}",
+ response_model=models.PodcastEpisodePublic,
+)
+async def episode_additional_upload(
+ auth: AuthDep,
+ session: SessionDep,
+ podcast_id: str,
+ episode_id: str,
+ file: UploadFile,
+):
+ db_episode = session.exec(
+ select(models.PodcastEpisode).where(
+ and_(
+ models.PodcastEpisode.id == episode_id,
+ models.PodcastEpisode.podcast_id == podcast_id,
+ )
+ )
+ ).first()
+
+ if not db_episode:
+ raise HTTPException(status_code=404, detail="Episode not found")
+
+ if db_episode.podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to update this episode"
+ )
+
+ new_episode = await process_additional_episode_upload(db_episode, file)
+
+ session.add(new_episode)
+ session.commit()
+ session.refresh(new_episode)
+ return new_episode
+
+
+@app.delete("/api/podcasts/{podcast_id}/episodes/{episode_id}")
+def delete_episode(
+ auth: AuthDep, session: SessionDep, podcast_id: str, episode_id: str
):
episode = session.exec(
select(models.PodcastEpisode).where(
and_(
models.PodcastEpisode.id == episode_id,
models.PodcastEpisode.podcast_id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
)
)
).first()
+ if not episode:
+ raise HTTPException(status_code=404, detail="Episode not found")
- if episode is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=404,
- context={
- "title": "Not found",
- "description": "The episode or podcast was not found.",
- },
+ if episode.podcast.owner_id != auth.user_id:
+ raise HTTPException(
+ status_code=403, detail="You do not have permission to delete this episode"
)
- (settings.directory / f"{episode_id}.m4a").unlink()
session.delete(episode)
session.commit()
-
- return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
-
-
-@app.get("/admin/{podcast_id}/{episode_id}/edit")
-def admin_edit_episode(
- session: SessionDep,
- request: Request,
- podcast_id: str,
- episode_id: str,
- user: AuthDep,
-):
- episode = session.exec(
- select(models.PodcastEpisode).where(
- and_(
- models.PodcastEpisode.id == episode_id,
- models.PodcastEpisode.podcast_id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
- ).first()
-
- if episode is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=404,
- context={
- "title": "Not found",
- "description": "The episode or podcast was not found.",
- },
- )
-
- return templates.TemplateResponse(
- request=request,
- name="admin_episode_edit.html.j2",
- context={"episode": episode},
- )
-
-
-@app.post("/admin/{podcast_id}/{episode_id}/edit")
-def admin_edit_episode_post(
- session: SessionDep,
- request: Request,
- podcast_id: str,
- episode_id: str,
- user: AuthDep,
- name: Annotated[str, Form()],
- description: Annotated[str, Form()],
-):
- episode = session.exec(
- select(models.PodcastEpisode).where(
- and_(
- models.PodcastEpisode.id == episode_id,
- models.PodcastEpisode.podcast_id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
- ).first()
-
- if episode is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=404,
- context={
- "title": "Not found",
- "description": "The episode or podcast was not found.",
- },
- )
-
- if name.strip() != "":
- episode.name = name
-
- if description.strip() != "":
- episode.description = description
- else:
- episode.description = None
-
- session.add(episode)
- session.commit()
-
- return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
-
-
-@app.get("/admin/{podcast_id}/edit")
-def admin_edit_podcast(
- session: SessionDep, request: Request, user: AuthDep, podcast_id: str
-):
- podcast = session.exec(
- select(models.Podcast).where(
- and_(
- models.Podcast.id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
- ).first()
-
- if podcast is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=404,
- context={
- "title": "Not found",
- "description": "The podcast was not found.",
- },
- )
-
- return templates.TemplateResponse(
- request=request,
- name="admin_feed_edit.html.j2",
- context={"podcast": podcast},
- )
-
-
-@app.post("/admin/{podcast_id}/edit")
-def admin_edit_podcast_post(
- session: SessionDep,
- request: Request,
- podcast_id: str,
- user: AuthDep,
- name: Annotated[str, Form()],
- description: Annotated[str, Form()],
- image: Optional[UploadFile] = None,
-):
- podcast = session.exec(
- select(models.Podcast).where(
- and_(
- models.Podcast.id == podcast_id,
- or_(
- models.Podcast.owner_id == user[0],
- models.Podcast.owner_id == None,
- ),
- )
- )
- ).first()
-
- if podcast is None:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=404,
- context={
- "title": "Not found",
- "description": "The podcast was not found.",
- },
- )
-
- if name.strip() != "":
- podcast.name = name
-
- podcast.description = description
-
- if image is not None and image.size > 0:
- if not (image.filename.endswith(".jpg") or image.filename.endswith(".png")):
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=400,
- context={
- "title": "Invalid entry",
- "description": "The uploaded podcast image must be a jpg or png.",
- },
- )
-
- im = Image.open(image.file)
-
- if im.size[0] != im.size[1] or im.size[0] < 1400 or im.size[0] > 3000:
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=400,
- context={
- "title": "Invalid entry",
- "description": "The uploaded podcast image must be square and between 1400x1400px and 3000x3000px in size.",
- },
- )
-
- if im.mode != "RGB":
- return templates.TemplateResponse(
- request,
- name="admin_error.html.j2",
- status_code=400,
- context={
- "title": "Invalid entry",
- "description": "The uploaded podcast image must be in RGB format.",
- },
- )
-
- if podcast.image_filename is not None:
- (settings.directory / podcast.image_filename).unlink(missing_ok=True)
-
- filename = f"img_{uuid.uuid4()}" + Path(image.filename).suffix
- im.save(settings.directory / filename)
- podcast.image_filename = filename
-
- session.add(podcast)
- session.commit()
-
- return RedirectResponse(f"/admin/{podcast_id}", status_code=303)
+ return {"ok": True}
@app.get("/{podcast_id}.xml")
@@ -543,9 +453,7 @@ def get_podcast_feed(session: SessionDep, request: Request, podcast_id: str):
if episode.duration is not None
else None,
),
- long_summary=markdown.markdown(episode.description)
- if episode.description is not None
- else None,
+ long_summary=render_markdown(episode.description),
)
)
@@ -582,3 +490,11 @@ def get_episode_or_cover(session: SessionDep, podcast_id: str, filename: str):
return FileResponse(settings.directory / podcast.image_filename)
return HTTPException(status_code=404, detail="File not found")
+
+
+@app.get("/{catchall:path}")
+async def serve_app(catchall: str):
+ return FileResponse("dist/index.html")
+
+
+use_route_names_as_operation_ids(app)
diff --git a/src/models.py b/src/models.py
index 8de1d7e..a8d191f 100644
--- a/src/models.py
+++ b/src/models.py
@@ -1,72 +1,92 @@
-import json
from datetime import datetime, timezone
-from typing import Optional
+from typing import List, Optional
import nanoid
+from pydantic import ConfigDict, computed_field
from sqlalchemy import Engine
-from sqlmodel import Field, Relationship, Session, SQLModel, create_engine
+from sqlmodel import Field, Relationship, SQLModel, create_engine
+from helpers import render_markdown
from settings import settings
-class Podcast(SQLModel, table=True):
- id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
+class PodcastBase(SQLModel):
name: str
description: str
explicit: bool = Field(default=True)
- image_filename: Optional[str] = Field(default=None)
- owner_id: Optional[str] = Field(default=None)
-
- episodes: list["PodcastEpisode"] = Relationship(back_populates="podcast")
-class PodcastEpisode(SQLModel, table=True):
+class Podcast(PodcastBase, table=True):
id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
+ owner_id: Optional[str] = Field(default=None)
+ episodes: List["PodcastEpisode"] = Relationship(
+ back_populates="podcast", cascade_delete=True
+ )
+ image_filename: Optional[str] = Field(default=None)
+
+
+class PodcastPublic(PodcastBase):
+ id: str
+ image_filename: Optional[str] = Field(default=None)
+
+
+class PodcastCreate(PodcastBase):
+ name: str = Field(min_length=1)
+ description: str = Field(min_length=1, default_factory=lambda data: data["name"])
+
+ model_config = ConfigDict(str_strip_whitespace=True)
+
+
+class PodcastUpdate(SQLModel):
+ name: Optional[str] = Field(min_length=1)
+ description: Optional[str] = Field(min_length=1)
+ explicit: Optional[bool] = Field(default=True)
+
+ model_config = ConfigDict(str_strip_whitespace=True)
+
+
+class PodcastEpisodeBase(SQLModel):
name: str
duration: Optional[float] = Field(default=None)
description: Optional[str] = Field(default=None)
file_hash: str
file_size: int
- publish_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ publish_date: datetime = Field(
+ default_factory=lambda: datetime.now(timezone.utc), nullable=False
+ )
+
+class PodcastEpisode(PodcastEpisodeBase, table=True):
+ id: str = Field(primary_key=True, default_factory=lambda: nanoid.generate())
podcast_id: str = Field(foreign_key="podcast.id")
podcast: Podcast = Relationship(back_populates="episodes")
+class PodcastEpisodePublic(PodcastEpisodeBase):
+ id: str
+ podcast_id: str
+
+ @computed_field
+ def description_html(self) -> Optional[str]:
+ return render_markdown(self.description)
+
+
+class PodcastEpisodeCreate(PodcastEpisodeBase):
+ podcast_id: str
+
+ model_config = ConfigDict(str_strip_whitespace=True)
+
+
+class PodcastEpisodeUpdate(SQLModel):
+ name: Optional[str] = Field(min_length=1)
+ description: Optional[str] = Field(min_length=1)
+ publish_date: Optional[datetime] = Field(default=None, nullable=True)
+
+ model_config = ConfigDict(str_strip_whitespace=True)
+
+
def setup_db(engine: Engine):
SQLModel.metadata.create_all(engine)
- # try and migrate old data
- old_data = settings.directory / "data.json"
- if old_data.is_file():
- try:
- session = Session(engine)
-
- with open(old_data, "r") as f:
- data = json.load(f)
-
- for id, item in data["podcasts"].items():
- podcast = Podcast(
- id=id,
- name=item.get("name"),
- description=item.get("description"),
- explicit=item.get("explicit"),
- image_filename=item.get("image_filename"),
- )
- session.add(podcast)
- session.commit()
-
- for episode in item["episodes"]:
- ep = PodcastEpisode.model_validate(
- {**episode, "podcast_id": podcast.id, "id": nanoid.generate()}
- )
- session.add(ep)
-
- session.commit()
-
- old_data.unlink()
- except Exception as ex:
- print("Failed to migrate old data", ex)
-
engine = create_engine(f"sqlite:///{settings.directory / 'data.db'}")
diff --git a/src/settings.py b/src/settings.py
index 709e3b8..3aa1f3e 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -1,17 +1,32 @@
from pathlib import Path
-from typing import Optional
+from typing import List
-from pydantic import Field
+from pydantic import BaseModel, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
directory: Path = Field(default=Path.cwd() / "data")
uploads_directory: Path = Field(default=Path.cwd() / "uploads")
- forward_auth_name_header: Optional[str] = Field(default=None)
- forward_auth_uid_header: Optional[str] = Field(default=None)
+ oidc_authorize_url: str = Field()
+ oidc_token_url: str = Field()
+ oidc_jwks_url: str = Field()
+ oidc_permitted_jwt_audiences: List[str] = Field()
+ oidc_client_id: str = Field()
+ oidc_sub_jwt_attribute: str = Field(default="sub")
+ oidc_name_jwt_attribute: str = Field(default="name")
+ oidc_scopes: List[str] = Field(
+ default=["openid", "email", "profile", "offline_access"]
+ )
+ oidc_authority: str = Field()
model_config = SettingsConfigDict(env_nested_delimiter="__", env_prefix="PG_")
+class AppConfig(BaseModel):
+ oidc_authority: str = Field()
+ oidc_client_id: str = Field()
+ oidc_scopes: List[str] = Field()
+
+
settings = Settings()
diff --git a/src/templates/admin_episode_edit.html.j2 b/src/templates/admin_episode_edit.html.j2
deleted file mode 100644
index a6d93da..0000000
--- a/src/templates/admin_episode_edit.html.j2
+++ /dev/null
@@ -1,22 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-{{ episode.name }}
-
-{% endblock %}
diff --git a/src/templates/admin_error.html.j2 b/src/templates/admin_error.html.j2
deleted file mode 100644
index 5d588b6..0000000
--- a/src/templates/admin_error.html.j2
+++ /dev/null
@@ -1,7 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-{{ title }}
-{{ description }}
-Go Back
-{% endblock %}
diff --git a/src/templates/admin_feed.html.j2 b/src/templates/admin_feed.html.j2
deleted file mode 100644
index f5fc71f..0000000
--- a/src/templates/admin_feed.html.j2
+++ /dev/null
@@ -1,116 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-{% if podcast.image_filename %}
-
-
-{% endif %}
-{{ podcast.name }}
-
- Actions:
- Edit
-
-
- Description:
- {{ podcast.description }}
-
-
- Subscribe:
-
{{ feed_uri }}
-For Apple Podcasts, open the app and click on the Library tab along the bottom. Select the ellipsis in the top
- right and hit "Follow a Show by URL...". For other apps, look for an option allowing you to add a show by
- URL.
-
-
-
-Upload Episode
-
-
Choose file to upload
-
-
-
-
-Episodes
-
-
-
- Name
- Published
- Length
- Actions
-
-
-
- {% for episode in episodes %}
-
- {{ episode.name }}
- {{ episode.publish_date.strftime("%H:%M %d/%m/%Y") }}
-
- {% if episode.duration %}
- {{ (episode.duration / 60) | round | int }}min
- {% else %}
- ?
- {% endif %}
-
-
- Delete
- Edit
-
-
- {% endfor %}
-
-
-
-
-{% endblock %}
diff --git a/src/templates/admin_feed_edit.html.j2 b/src/templates/admin_feed_edit.html.j2
deleted file mode 100644
index 7940f6d..0000000
--- a/src/templates/admin_feed_edit.html.j2
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-{{ podcast.name }}
-
-
-
- Name
-
-
-
- Description
- {{ podcast.description }}
-
-
- Image
-
- This must be a square JPG or PNG in RGB format, and at least 1400x1400px in size and
- 3000x3000px at most.
-
-
-
-
-
-{% endblock %}
diff --git a/src/templates/admin_feeds.html.j2 b/src/templates/admin_feeds.html.j2
deleted file mode 100644
index 1ce44b2..0000000
--- a/src/templates/admin_feeds.html.j2
+++ /dev/null
@@ -1,14 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-Hello {{ user_name }}!
-My Podcasts
-
- Actions: Create
-
-
-{% endblock %}
diff --git a/src/templates/admin_layout.html.j2 b/src/templates/admin_layout.html.j2
deleted file mode 100644
index 0eea34b..0000000
--- a/src/templates/admin_layout.html.j2
+++ /dev/null
@@ -1,11 +0,0 @@
-{% extends 'layout.html.j2' %}
-{% block content %}
-
-
-
-
-{% endblock %}
diff --git a/src/templates/admin_new.html.j2 b/src/templates/admin_new.html.j2
deleted file mode 100644
index 428e0d2..0000000
--- a/src/templates/admin_new.html.j2
+++ /dev/null
@@ -1,15 +0,0 @@
-{% extends 'admin_layout.html.j2' %}
-{% block content %}
-{{ super() }}
-New Podcast
-
-
-
- Name
-
-
-
-
-
-
-{% endblock %}
diff --git a/src/templates/layout.html.j2 b/src/templates/layout.html.j2
deleted file mode 100644
index 5cef0c8..0000000
--- a/src/templates/layout.html.j2
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
- Podcast Server
-
-
-
-
- {% block content %}{% endblock %}
-
-
-
-
diff --git a/uv.lock b/uv.lock
index 288fe3c..5e3e4d4 100644
--- a/uv.lock
+++ b/uv.lock
@@ -10,6 +10,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896 },
]
+[[package]]
+name = "alembic"
+version = "1.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mako" },
+ { name = "sqlalchemy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/99/09/f844822e4e847a3f0bd41797f93c4674cd4d2462a3f6c459aa528cdf786e/alembic-1.14.1.tar.gz", hash = "sha256:496e888245a53adf1498fcab31713a469c65836f8de76e01399aa1c3e90dd213", size = 1918219 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/7e/ac0991d1745f7d755fc1cd381b3990a45b404b4d008fc75e2a983516fbfe/alembic-1.14.1-py3-none-any.whl", hash = "sha256:1acdd7a3a478e208b0503cd73614d5e4c6efafa4e73518bb60e4f2846a37b1c5", size = 233565 },
+]
+
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -32,6 +46,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
]
+[[package]]
+name = "bleach"
+version = "6.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 },
+]
+
[[package]]
name = "certifi"
version = "2024.12.14"
@@ -41,6 +67,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
]
+[[package]]
+name = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.1"
@@ -96,6 +144,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424 },
]
+[[package]]
+name = "cryptography"
+version = "44.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 },
+ { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 },
+ { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 },
+ { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 },
+ { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 },
+ { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 },
+ { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 },
+ { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 },
+ { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 },
+ { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 },
+ { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 },
+ { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 },
+ { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 },
+ { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 },
+ { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 },
+ { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 },
+ { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 },
+ { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 },
+ { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 },
+ { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 },
+]
+
[[package]]
name = "dateutils"
version = "0.6.12"
@@ -304,6 +383,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
+]
+
[[package]]
name = "jinja2"
version = "3.1.5"
@@ -341,6 +429,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/db/214290d58ad68c587bd5d6af3d34e56830438733d0d0856c0275fde43652/lxml-5.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:406246b96d552e0503e17a1006fd27edac678b3fcc9f1be71a2f94b4ff61528d", size = 3814417 },
]
+[[package]]
+name = "mako"
+version = "1.3.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/d9/8518279534ed7dace1795d5a47e49d5299dd0994eed1053996402a8902f9/mako-1.3.8.tar.gz", hash = "sha256:577b97e414580d3e088d47c2dbbe9594aa7a5146ed2875d4dfa9075af2dd3cc8", size = 392069 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/bf/7a6a36ce2e4cafdfb202752be68850e22607fccd692847c45c1ae3c17ba6/Mako-1.3.8-py3-none-any.whl", hash = "sha256:42f48953c7eb91332040ff567eb7eea69b22e7a4affbc5ba8e845e8f730f6627", size = 78569 },
+]
+
[[package]]
name = "markdown"
version = "3.7"
@@ -441,35 +541,49 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
+ { name = "alembic" },
+ { name = "bleach" },
+ { name = "cryptography" },
{ name = "fastapi", extra = ["standard"] },
{ name = "ffmpeg-normalize" },
{ name = "ffmpeg-python" },
+ { name = "itsdangerous" },
{ name = "markdown" },
{ name = "nanoid" },
{ name = "pillow" },
{ name = "podgen" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
+ { name = "pyjwt" },
{ name = "python-multipart" },
+ { name = "requests" },
{ name = "sqlmodel" },
{ name = "structlog" },
+ { name = "uvicorn" },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles", specifier = ">=24.1.0" },
+ { name = "alembic", specifier = ">=1.14.1" },
+ { name = "bleach", specifier = ">=6.2.0" },
+ { name = "cryptography", specifier = ">=44.0.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.6" },
{ name = "ffmpeg-normalize", specifier = ">=1.31.0" },
{ name = "ffmpeg-python", specifier = ">=0.2.0" },
+ { name = "itsdangerous", specifier = ">=2.2.0" },
{ name = "markdown", specifier = ">=3.7" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "podgen", specifier = ">=1.1.0" },
{ name = "pydantic", specifier = ">=2.10.5" },
{ name = "pydantic-settings", specifier = ">=2.7.1" },
+ { name = "pyjwt", specifier = ">=2.10.1" },
{ name = "python-multipart", specifier = ">=0.0.20" },
+ { name = "requests", specifier = ">=2.32.3" },
{ name = "sqlmodel", specifier = ">=0.0.22" },
{ name = "structlog", specifier = ">=24.4.0" },
+ { name = "uvicorn", specifier = ">=0.34.0" },
]
[[package]]
@@ -489,6 +603,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/f9/c78c6c4ab0474dc095c8037e18484e02149c33f9a0870bdf1cc6ce7eb339/podgen-1.1.0-py2.py3-none-any.whl", hash = "sha256:0ee7b262c131773e5973a63a56a5ee96375ea522b1c30ccd4ec4c35e0c1bb2c6", size = 38268 },
]
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
+]
+
[[package]]
name = "pydantic"
version = "2.10.5"
@@ -550,6 +673,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
]
+[[package]]
+name = "pyjwt"
+version = "2.10.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -845,6 +977,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f0/da/725f97a8b1b4e7b3e4331cce3ef921b12568af3af403b9f0f61ede036898/watchfiles-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:160eff7d1267d7b025e983ca8460e8cc67b328284967cbe29c05f3c3163711a3", size = 285246 },
]
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 },
+]
+
[[package]]
name = "websockets"
version = "14.1"