This commit is contained in:
2024-01-04 09:02:44 +01:00
parent ed19c7c6cb
commit 689c71deb1
48 changed files with 1464 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
.web
__pycache__/*

68
.drone.jsonnet Normal file
View File

@@ -0,0 +1,68 @@
[
{
kind: 'pipeline',
type: 'docker',
name: 'reflex-ipad',
steps: [
{
name: 'builder',
image: 'plugins/docker',
settings: {
tags: ['builder'],
dockerfile: 'Dockerfile.builder',
registry: 'gitea.pb42.de',
repo: 'gitea.pb42.de/matthias/reflex-ipad',
config: { from_secret: 'dockerconfigjson' },
},
},
{
name: 'frontend-builder',
image: 'gitea.pb42.de/matthias/reflex-ipad',
commands:[
'reflex export --frontend-only --no-zip',
'mv .web/_static /drone/src/web',
],
depends_on: [
'builder'
],
},
{
name: 'backend',
image: 'plugins/docker',
settings: {
tags: ['backend-latest'],
dockerfile: 'Dockerfile.backend',
registry: 'gitea.pb42.de',
repo: 'gitea.pb42.de/matthias/reflex-ipad',
config: { from_secret: 'dockerconfigjson' },
build_args: ['API_URL=https://ipad.pb42.de/api'],
},
depends_on: [
'builder'
],
},
{
name: "deploy_web",
image: "appleboy/drone-scp",
settings: {
host: "pb42.de",
target: "/",
source: "web/*",
username: {
from_secret: "deploy_username",
},
password: {
from_secret: "deploy_password",
},
port: 42022,
},
depends_on: [
'frontend'
],
},
]
trigger: { event: ['push'] },
image_pull_secrets: ['dockerconfigjson'],
},
]

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
*.db
*.py[cod]
.web
__pycache__/
venv

4
Caddy.Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM library/caddy
COPY --from=local/reflex-app /app/.web/_static /srv
ADD Caddyfile /etc/caddy/Caddyfile

14
Caddyfile Normal file
View File

@@ -0,0 +1,14 @@
{$DOMAIN}
encode gzip
@backend_routes path /_event/* /_upload /ping
handle @backend_routes {
reverse_proxy app:8000
}
root * /srv
route {
try_files {path} {path}/ /404.html
file_server
}

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Stage 1: init
FROM python:3.11 as init
# Pass `--build-arg API_URL=http://app.example.com:8000` during build
ARG API_URL
# Copy local context to `/app` inside container (see .dockerignore)
WORKDIR /app
COPY requirements.txt .
# Create virtualenv which will be copied into final container
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python3.11 -m venv $VIRTUAL_ENV
# Install app requirements and reflex inside virtualenv
RUN pip install -r requirements.txt
COPY . .
# Deploy templates and prepare app
RUN reflex init

10
Dockerfile.backend Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
ARG API_URL
WORKDIR /app
RUN adduser --disabled-password --home /app reflex
COPY --chown=reflex --from=gitea.pb42.de/matthias/reflex-ipad:builder /app /app
COPY --chown=reflex deploy/* .
USER reflex
ENV PATH="/app/.venv/bin:$PATH" API_URL=$API_URL
CMD reflex db migrate && reflex run --env prod --backend-only

22
Dockerfile.builder Normal file
View File

@@ -0,0 +1,22 @@
# Stage 1: init
FROM python:3.11 as init
# Pass `--build-arg API_URL=http://app.example.com:8000` during build
ARG API_URL
# Copy local context to `/app` inside container (see .dockerignore)
WORKDIR /app
COPY requirements.txt .
# Create virtualenv which will be copied into final container
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN python3.11 -m venv $VIRTUAL_ENV
# Install app requirements and reflex inside virtualenv
RUN pip install -r requirements.txt
COPY . .
# Deploy templates and prepare app
RUN reflex init

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
# Reflex IPAD
This is a small webinterface for several usages.
## Medicine
- Log medicine
- get reminder if not taken
-

116
alembic.ini Normal file
View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# 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 = .
# 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.
# 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 alembic/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:alembic/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 = os # Use os.pathsep. Default configuration used for new projects.
# 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 = driver://user:pass@localhost/dbname
[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 = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
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

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

78
alembic/env.py Normal file
View File

@@ -0,0 +1,78 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 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 = None
# 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()

26
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${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
${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"}

View File

@@ -0,0 +1,39 @@
"""empty message
Revision ID: 44b022dca7ce
Revises: d64b9935ffd3
Create Date: 2024-01-01 16:46:40.302737
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '44b022dca7ce'
down_revision: Union[str, None] = 'd64b9935ffd3'
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('scan',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('uuid', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('timestamp', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_scan_timestamp'), 'scan', ['timestamp'], unique=False)
op.create_index(op.f('ix_scan_uuid'), 'scan', ['uuid'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_scan_uuid'), table_name='scan')
op.drop_index(op.f('ix_scan_timestamp'), table_name='scan')
op.drop_table('scan')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""empty message
Revision ID: 5faf3b4df7ee
Revises: 44b022dca7ce
Create Date: 2024-01-02 14:01:13.882740
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '5faf3b4df7ee'
down_revision: Union[str, None] = '44b022dca7ce'
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.add_column('medicine', sa.Column('cron', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('medicine', 'cron')
# ### end Alembic commands ###

View File

@@ -0,0 +1,54 @@
"""empty message
Revision ID: b21476e11a57
Revises:
Create Date: 2024-01-01 14:11:31.822410
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'b21476e11a57'
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('owner',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('medicine',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('owner_id', sa.Integer(), nullable=True),
sa.Column('pzn', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('uuid', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['owner.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_medicine_uuid'), 'medicine', ['uuid'], unique=False)
op.create_table('medicinelog',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('medicine_id', sa.Integer(), nullable=True),
sa.Column('timestamp', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['medicine_id'], ['medicine.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('medicinelog')
op.drop_index(op.f('ix_medicine_uuid'), table_name='medicine')
op.drop_table('medicine')
op.drop_table('owner')
# ### end Alembic commands ###

View File

@@ -0,0 +1,30 @@
"""empty message
Revision ID: d64b9935ffd3
Revises: b21476e11a57
Create Date: 2024-01-01 16:24:12.182247
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'd64b9935ffd3'
down_revision: Union[str, None] = 'b21476e11a57'
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.add_column('medicine', sa.Column('package_size', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('medicine', 'package_size')
# ### end Alembic commands ###

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

10
assets/github.svg Normal file
View File

@@ -0,0 +1,10 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Github" clip-path="url(#clip0_469_1929)">
<path id="Vector" d="M8.0004 0.587524C3.80139 0.587524 0.400391 3.98851 0.400391 8.1875C0.400391 11.5505 2.57589 14.391 5.59689 15.398C5.97689 15.4645 6.11939 15.2365 6.11939 15.037C6.11939 14.8565 6.10989 14.258 6.10989 13.6215C4.20039 13.973 3.70639 13.156 3.55439 12.7285C3.46889 12.51 3.09839 11.8355 2.77539 11.655C2.50939 11.5125 2.12939 11.161 2.76589 11.1515C3.36439 11.142 3.79189 11.7025 3.93439 11.9305C4.61839 13.08 5.71089 12.757 6.14789 12.5575C6.21439 12.0635 6.41388 11.731 6.6324 11.541C4.94139 11.351 3.17439 10.6955 3.17439 7.7885C3.17439 6.962 3.46889 6.27801 3.95339 5.74601C3.87739 5.55601 3.61139 4.77701 4.02939 3.73201C4.02939 3.73201 4.66589 3.53251 6.11939 4.51101C6.7274 4.34001 7.3734 4.25451 8.0194 4.25451C8.6654 4.25451 9.3114 4.34001 9.9194 4.51101C11.3729 3.52301 12.0094 3.73201 12.0094 3.73201C12.4274 4.77701 12.1614 5.55601 12.0854 5.74601C12.5699 6.27801 12.8644 6.9525 12.8644 7.7885C12.8644 10.705 11.0879 11.351 9.3969 11.541C9.6724 11.7785 9.9099 12.2345 9.9099 12.947C9.9099 13.9635 9.9004 14.7805 9.9004 15.037C9.9004 15.2365 10.0429 15.474 10.4229 15.398C13.5165 14.3536 15.5996 11.4527 15.6004 8.1875C15.6004 3.98851 12.1994 0.587524 8.0004 0.587524Z" fill="#494369"/>
</g>
<defs>
<clipPath id="clip0_469_1929">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

37
assets/icon.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg width="67" height="14" viewBox="0 0 67 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="67" height="14" fill="#1E1E1E"/>
<g id="Nav Template &#62; Initial" clip-path="url(#clip0_0_1)">
<rect width="1440" height="1024" transform="translate(-16 -17)" fill="white"/>
<g id="Sidebar">
<g clip-path="url(#clip1_0_1)">
<path d="M-16 -17H264V1007H-16V-17Z" fill="white"/>
<g id="Header">
<path d="M-16 -17H264V31H-16V-17Z" fill="white"/>
<g id="Button">
<rect x="-4" y="-3" width="74.316" height="20" rx="6" fill="white"/>
<g id="Logo">
<g id="Reflex">
<path d="M0 13.6316V0.368408H10.6106V5.67369H7.95792V3.02105H2.65264V5.67369H7.95792V8.32633H2.65264V13.6316H0ZM7.95792 13.6316V8.32633H10.6106V13.6316H7.95792Z" fill="#110F1F"/>
<path d="M13.2632 13.6316V0.368408H21.2211V3.02105H15.9158V5.67369H21.2211V8.32633H15.9158V10.979H21.2211V13.6316H13.2632Z" fill="#110F1F"/>
<path d="M23.8738 13.6316V0.368408H31.8317V3.02105H26.5264V5.67369H31.8317V8.32633H26.5264V13.6316H23.8738Z" fill="#110F1F"/>
<path d="M34.4843 13.6316V0.368408H37.137V10.979H42.4422V13.6316H34.4843Z" fill="#110F1F"/>
<path d="M45.0949 13.6316V0.368408H53.0528V3.02105H47.7475V5.67369H53.0528V8.32633H47.7475V10.979H53.0528V13.6316H45.0949Z" fill="#110F1F"/>
<path d="M55.7054 5.67369V0.368408H58.3581V5.67369H55.7054ZM63.6634 5.67369V0.368408H66.316V5.67369H63.6634ZM58.3581 8.32633V5.67369H63.6634V8.32633H58.3581ZM55.7054 13.6316V8.32633H58.3581V13.6316H55.7054ZM63.6634 13.6316V8.32633H66.316V13.6316H63.6634Z" fill="#110F1F"/>
</g>
</g>
</g>
<path d="M264 30.5H-16V31.5H264V30.5Z" fill="#F4F3F6"/>
</g>
</g>
<path d="M263.5 -17V1007H264.5V-17H263.5Z" fill="#F4F3F6"/>
</g>
</g>
<defs>
<clipPath id="clip0_0_1">
<rect width="1440" height="1024" fill="white" transform="translate(-16 -17)"/>
</clipPath>
<clipPath id="clip1_0_1">
<path d="M-16 -17H264V1007H-16V-17Z" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

68
assets/logo.svg Normal file
View File

@@ -0,0 +1,68 @@
<svg width="80" height="78" viewBox="0 0 80 78" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_ddddi_449_2821)">
<path d="M13 11C13 6.58172 16.5817 3 21 3H59C63.4183 3 67 6.58172 67 11V49C67 52.3137 64.3137 55 61 55H19C15.6863 55 13 52.3137 13 49V11Z" fill="url(#paint0_radial_449_2821)"/>
<path d="M13 11C13 6.58172 16.5817 3 21 3H59C63.4183 3 67 6.58172 67 11V49C67 52.3137 64.3137 55 61 55H19C15.6863 55 13 52.3137 13 49V11Z" fill="url(#paint1_radial_449_2821)"/>
<g filter="url(#filter1_i_449_2821)">
<path d="M31 37.5C30.4477 37.5 30 37.0523 30 36.5V13.5001C30 12.9478 30.4477 12.5001 31 12.5001H49C49.5523 12.5001 50 12.9478 50 13.5001V21.5001C50 22.0524 49.5523 22.5001 49 22.5001H45V18.5001C45 17.9478 44.5523 17.5001 44 17.5001H36C35.4477 17.5001 35 17.9478 35 18.5001V21.5001C35 22.0524 35.4477 22.5001 36 22.5001H45V27.5001H36C35.4477 27.5001 35 27.9478 35 28.5001V36.5C35 37.0523 34.5523 37.5 34 37.5H31ZM46 37.5C45.4477 37.5 45 37.0523 45 36.5V27.5001H49C49.5523 27.5001 50 27.9478 50 28.5001V36.5C50 37.0523 49.5523 37.5 49 37.5H46Z" fill="url(#paint2_radial_449_2821)"/>
</g>
<path d="M13 11C13 6.58172 16.5817 3 21 3H59C63.4183 3 67 6.58172 67 11V49C67 52.3137 64.3137 55 61 55H19C15.6863 55 13 52.3137 13 49V11Z" stroke="#20117E" stroke-opacity="0.04"/>
</g>
<defs>
<filter id="filter0_ddddi_449_2821" x="0.5" y="0.5" width="79" height="77" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect1_dropShadow_449_2821"/>
<feOffset dy="10"/>
<feGaussianBlur stdDeviation="8"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0784314 0 0 0 0 0.0705882 0 0 0 0 0.231373 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_449_2821"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="6" operator="erode" in="SourceAlpha" result="effect2_dropShadow_449_2821"/>
<feOffset dy="12"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.0784314 0 0 0 0 0.0705882 0 0 0 0 0.231373 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_449_2821" result="effect2_dropShadow_449_2821"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="4" operator="erode" in="SourceAlpha" result="effect3_dropShadow_449_2821"/>
<feOffset dy="10"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.12549 0 0 0 0 0.0666667 0 0 0 0 0.494118 0 0 0 0.16 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_449_2821" result="effect3_dropShadow_449_2821"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feMorphology radius="1" operator="dilate" in="SourceAlpha" result="effect4_dropShadow_449_2821"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.12549 0 0 0 0 0.0666667 0 0 0 0 0.494118 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect3_dropShadow_449_2821" result="effect4_dropShadow_449_2821"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_449_2821" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-8"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.678431 0 0 0 0 0.607843 0 0 0 0 0.972549 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="shape" result="effect5_innerShadow_449_2821"/>
</filter>
<filter id="filter1_i_449_2821" x="30" y="12.5001" width="20" height="26.9999" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.12549 0 0 0 0 0.0666667 0 0 0 0 0.494118 0 0 0 0.32 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_449_2821"/>
</filter>
<radialGradient id="paint0_radial_449_2821" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 3) rotate(90) scale(52 54)">
<stop stop-color="white" stop-opacity="0.9"/>
<stop offset="1" stop-color="#4E3DB9" stop-opacity="0.24"/>
</radialGradient>
<radialGradient id="paint1_radial_449_2821" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 3) rotate(90) scale(52 54)">
<stop stop-color="white"/>
<stop offset="1" stop-color="#F7F7F7"/>
</radialGradient>
<radialGradient id="paint2_radial_449_2821" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 12.5001) rotate(90) scale(24.9999 20)">
<stop stop-color="#F5F3FF"/>
<stop stop-color="white"/>
<stop offset="1" stop-color="#E1DDF4"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

13
assets/paneleft.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="PaneLeft" clip-path="url(#clip0_469_1942)">
<g id="Vector">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.80217 0.525009C7.34654 0.525009 6.97717 0.894373 6.97717 1.35001V10.65C6.97717 11.1056 7.34654 11.475 7.80217 11.475H10.6522C11.1078 11.475 11.4772 11.1056 11.4772 10.65V1.35001C11.4772 0.894373 11.1078 0.525009 10.6522 0.525009H7.80217ZM8.02717 10.425V1.57501H10.4272V10.425H8.02717Z" fill="#494369"/>
<path d="M3.78215 8.14502L2.16213 6.525H5.92717V5.475H2.16213L3.78215 3.85498L3.03969 3.11252L0.523438 5.62877V6.37123L3.03969 8.88748L3.78215 8.14502Z" fill="#494369"/>
</g>
</g>
<defs>
<clipPath id="clip0_469_1942">
<rect width="12" height="12" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 807 B

20
compose.yaml Normal file
View File

@@ -0,0 +1,20 @@
# During build and run, set environment DOMAIN pointing
# to publicly accessible domain where app will be hosted
services:
app:
image: local/reflex-app
build:
context: .
args:
API_URL: https://${DOMAIN:-localhost}
webserver:
environment:
DOMAIN: ${DOMAIN:-localhost}
ports:
- 80:80 # for acme-challenge via HTTP
build:
context: .
dockerfile: Caddy.Dockerfile
depends_on:
- app

7
deploy/rxconfig.py Normal file
View File

@@ -0,0 +1,7 @@
import reflex as rx
config = rx.Config(
app_name="reflex_ipad",
db_url="postgresql://ipad:uizJOcDZR3qKLUJuAeSr8WG8onJ1vsUQB2zgE5NIKcpLGT5EF3x7JBkPs@db:5432/ipad",
env=rx.Env.prod,
)

17
docker_compose.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
app:
image: gitea.pb42.de/matthias/reflex-ipad:latest
force_pull: true
db:
image: postgres:16
restart: always
environment:
- POSTGRES_USER=ipad
- POSTGRES_PASSWORD=uizJOcDZR3qKLUJuAeSr8WG8onJ1vsUQB2zgE5NIKcpLGT5EF3x7JBkPs
- POSTGRES_DB=ipad
networks:
- gitea2
volumes:
- /var/docker/ipad/db/data:/var/lib/postgresql/data
shm_size: 256mb

1
reflex_ipad/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Base template for Reflex."""

View File

@@ -0,0 +1,6 @@
from . import medicine
from . import scan
def register_at(app):
medicine.register_at(app)
scan.register_at(app)

View File

@@ -0,0 +1,13 @@
import reflex as rx
from reflex_ipad.models import *
from sqlmodel import Field, Session, SQLModel, create_engine, select
async def medicine_scan(uuid: str):
with rx.session() as session:
statement = select(Medicine).where(Medicine.uuid == uuid)
results = session.exec(statement)
med = results.first()
return med
def register_at(app):
app.api.add_api_route("/medicine/scan/{uuid}", medicine_scan)

17
reflex_ipad/api/scan.py Normal file
View File

@@ -0,0 +1,17 @@
import reflex as rx
from reflex_ipad.models import *
from sqlmodel import Field, Session, SQLModel, create_engine, select
import time
async def scan(uuid: str):
print(uuid)
with rx.session() as session:
scan = Scan(uuid=uuid, timestamp=time.time())
session.add(scan)
session.commit()
return scan.as_dict()
def register_at(app):
print("register scan")
app.api.add_api_route("/scan/{uuid}", scan)

View File

View File

@@ -0,0 +1,119 @@
"""Sidebar component for the app."""
from reflex_ipad import styles
import reflex as rx
def sidebar_header() -> rx.Component:
"""Sidebar header.
Returns:
The sidebar header component.
"""
return rx.hstack(
# The logo.
rx.heading("Hallo"),
width="100%",
border_bottom=styles.border,
padding="1em",
)
def sidebar_footer() -> rx.Component:
"""Sidebar footer.
Returns:
The sidebar footer component.
"""
return rx.hstack(
rx.spacer(),
width="100%",
border_top=styles.border,
padding="1em",
)
def sidebar_item(text: str, icon: str, url: str) -> rx.Component:
"""Sidebar item.
Args:
text: The text of the item.
icon: The icon of the item.
url: The URL of the item.
Returns:
rx.Component: The sidebar item component.
"""
# Whether the item is active.
active = (rx.State.router.page.path == f"/{text.lower()}") | (
(rx.State.router.page.path == "/") & text == "Home"
)
return rx.link(
rx.hstack(
rx.image(
src=icon,
height="2.5em",
padding="0.5em",
),
rx.text(
text,
),
bg=rx.cond(
active,
styles.accent_color,
"transparent",
),
color=rx.cond(
active,
styles.accent_text_color,
styles.text_color,
),
border_radius=styles.border_radius,
box_shadow=styles.box_shadow,
width="100%",
padding_x="1em",
),
href=url,
width="100%",
)
def sidebar() -> rx.Component:
"""The sidebar.
Returns:
The sidebar component.
"""
# Get all the decorated pages and add them to the sidebar.
from reflex.page import get_decorated_pages
return rx.box(
rx.vstack(
sidebar_header(),
rx.vstack(
*[
sidebar_item(
text=page.get("title", page["route"].strip("/").capitalize()),
icon=page.get("image", "/github.svg"),
url=page["route"],
)
for page in get_decorated_pages()
],
width="100%",
overflow_y="auto",
align_items="flex-start",
padding="1em",
),
rx.spacer(),
sidebar_footer(),
height="100dvh",
),
display=["none", "none", "block"],
min_width=styles.sidebar_width,
height="100%",
position="sticky",
top="0px",
border_right=styles.border,
)

View File

@@ -0,0 +1,2 @@
from .medicine import *
from .scan import *

View File

@@ -0,0 +1,6 @@
import reflex as rx
class BaseModel(rx.Model):
def as_dict(self):
return {c.name: getattr(self, c.name) for c in self.__table__.columns}

View File

@@ -0,0 +1,29 @@
import reflex as rx
from typing import Optional, List
from sqlmodel import Field, Session, SQLModel, create_engine, select, Relationship
class Owner(rx.Model, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
medicines: List["Medicine"] = Relationship(back_populates="owner")
class Medicine(rx.Model, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
owner_id: int = Field(default=None, foreign_key="owner.id")
owner: Owner = Relationship(back_populates="medicines")
pzn: str
package_size: Optional[int] = Field(default=None)
uuid: str = Field(index=True)
log: List["MedicineLog"] = Relationship(back_populates="medicine")
cron: Optional[str] = Field(default=None)
class MedicineLog(rx.Model, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
medicine_id: Optional[int] = Field(default=None, foreign_key="medicine.id")
medicine: Optional[Medicine] = Relationship(back_populates="log")
timestamp: int

View File

@@ -0,0 +1,11 @@
import reflex as rx
from .base import *
from typing import Optional, List
from sqlmodel import Field, Session, SQLModel, create_engine, select, Relationship
class Scan(BaseModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
uuid: str = Field(index=True)
timestamp: int = Field(index=True)

View File

@@ -0,0 +1,6 @@
from .dashboard import dashboard
from .index import index
from .settings import settings
from .medicine import medicine

View File

@@ -0,0 +1,21 @@
"""The dashboard page."""
from reflex_ipad.templates import template
import reflex as rx
@template(route="/dashboard", title="Dashboard")
def dashboard() -> rx.Component:
"""The dashboard page.
Returns:
The UI for the dashboard page.
"""
return rx.vstack(
rx.heading("Dashboard", font_size="3em"),
rx.text("Welcome to Reflex!"),
rx.text(
"You can edit this page in ",
rx.code("{your_app}/pages/dashboard.py"),
),
)

View File

@@ -0,0 +1,18 @@
"""The home page of the app."""
from reflex_ipad import styles
from reflex_ipad.templates import template
import reflex as rx
@template(route="/", title="Home", image="/github.svg")
def index() -> rx.Component:
"""The home page.
Returns:
The UI for the home page.
"""
with open("README.md", encoding="utf-8") as readme:
content = readme.read()
return rx.markdown(content, component_map=styles.markdown_style)

View File

@@ -0,0 +1,246 @@
"""The meds page."""
from reflex_ipad import styles
from reflex_ipad.templates import template
from reflex_ipad.state import State
import datetime
import asyncio
import time
import reflex as rx
from reflex_ipad.models import *
from sqlmodel import Field, Session, SQLModel, create_engine, select
class NewMedicineState(rx.State):
"""Define your app state here."""
show_med_add_form: bool = False
medicine: Optional[Medicine] = None
medicine_name: str = ""
last_scan: Scan = Scan(uuid="", timestamp=0)
last_scan_uuid: str = ""
last_scan_time: str = ""
updated_uuid: bool = False
scanning: bool = False
rate: float = 2
lastupdatetime: int = time.time()
def start_scan(self):
self.set_scanning(True)
self.lastupdatetime = time.time()
self.last_scan_uuid = ""
return NewMedicineState.do_scanning
def load_last_scan(self):
with rx.session() as session:
statement = select(Scan).order_by(Scan.timestamp.desc()).limit(1)
results = session.exec(statement)
self.last_scan = results.first()
if self.last_scan.timestamp > self.lastupdatetime:
self.last_scan_uuid = self.last_scan.uuid
if not self.show_med_add_form:
self.update_medicine(self.last_scan.uuid)
self.updated_uuid = True
self.lastupdatetime = time.time()
ts = datetime.datetime.fromtimestamp(self.last_scan.timestamp)
self.last_scan_time = ts.strftime("%Y-%m-%d %H:%M")
def uuid_used(self):
self.updated_uuid = False
def update_medicine(self, uuid):
with rx.session() as session:
statement = select(Medicine).where(Medicine.uuid == uuid)
results = session.exec(statement)
self.medicine = results.first()
if self.medicine is not None:
self.medicine_name = f"{self.medicine.name} - {self.medicine.owner.name}"
@rx.background
async def do_scanning(self):
while True:
await asyncio.sleep(1 / self.rate)
if not self.scanning:
break
async with self:
self.load_last_scan()
def do_show_med_add_form(self):
self.set_show_med_add_form(True)
def stop_show_med_add_form(self):
self.set_show_med_add_form(False)
def handle_submit(self, form_data: dict):
self.form_data = form_data
with rx.session() as session:
statement = select(Owner).where(Owner.name == form_data["owner"])
results = session.exec(statement)
owner = results.first()
medicine = Medicine(
name=form_data["name"],
package_size=form_data["pkg_size"],
pzn=form_data["pzn"] or "",
owner_id=owner.id,
uuid=State.last_scan_uuid,
cron=form_data["schedule"],
)
session.add(medicine)
session.commit()
self.uuid_used()
self.set_show_med_add_form(False)
def handle_log(self, form_data: dict):
with rx.session() as session:
statement = select(Medicine).where(Medicine.uuid == form_data["uuid"])
results = session.exec(statement)
medicine = results.first()
medicineLog = MedicineLog(medicine_id=medicine.id, timestamp=time.time())
session.add(medicineLog)
session.commit()
self.uuid_used()
def cancel_log(self, form_data: dict):
self.uuid_used()
def new_medicine_form():
with rx.session() as session:
statement = select(Owner)
results = session.exec(statement)
owners = results.all()
return rx.vstack(
rx.form(
rx.vstack(
rx.input(
placeholder="Name",
name="name",
),
rx.input(
placeholder="Packungsgröße",
name="pkg_size",
),
rx.input(
placeholder="PZN",
name="pzn",
),
rx.select(
[owner.name for owner in owners],
placeholder="Für wen",
name="owner",
),
rx.hstack(
rx.text("Crontab lines für Einnahme"),
rx.popover(
rx.popover_trigger(rx.button("Help")),
rx.popover_content(
rx.popover_header("Crontab help"),
rx.popover_body(
rx.html("<pre>0 7 * * * </pre>"),
"Täglich sieben Uhr",
),
rx.popover_body(
rx.html("<pre>0 21 * * 0 </pre>"),
"Jeden Sonntag um 21:00 Uhr.",
),
rx.popover_body(
rx.html("<pre>0 7 * * 1-5 </pre>"),
"Montags bis Freitags jeweils um 07:00",
),
rx.popover_close_button(),
),
),
),
rx.text_area(name="schedule"),
rx.input(
value=NewMedicineState.last_scan_uuid,
name="uuid",
placeholder="UUID",
disabled=True,
),
rx.hstack(
rx.text("Gescannt:"),
rx.text(
NewMedicineState.last_scan_time,
),
),
rx.button("Submit", type_="submit"),
),
on_submit=NewMedicineState.handle_submit,
reset_on_submit=True,
),
rx.divider(),
)
def taken_form():
with rx.session() as session:
if NewMedicineState.medicine is None:
return rx.vstack()
return rx.vstack(
rx.heading(NewMedicineState.medicine_name, size="lg", color="darkblue"),
rx.hstack(
rx.form(
rx.button("Ja", type_="submit", color_scheme="green", size="lg"),
rx.input(
value=NewMedicineState.last_scan_uuid,
name="uuid",
disabled=True,
hidden=True,
type_="hidden",
),
on_submit=NewMedicineState.handle_log,
),
rx.form(
rx.button("Nein", type_="submit", color_scheme="red", size="lg"),
on_submit=NewMedicineState.cancel_log,
),
reset_on_submit=True,
),
rx.divider(),
)
@template(route="/medicine", title="Medikamente")
def medicine() -> rx.Component:
"""The dashboard page.
Returns:
The UI for the dashboard page.
"""
return rx.vstack(
rx.hstack(
rx.spacer(),
rx.heading("Medikamente", font_size="3em"),
rx.spacer(),
rx.cond(
NewMedicineState.show_med_add_form,
rx.button(
rx.text(
"-",
),
on_click=NewMedicineState.stop_show_med_add_form(),
),
rx.button(
rx.text(
"+",
),
on_click=NewMedicineState.do_show_med_add_form(),
),
),
width="100%",
border_bottom=styles.border,
padding="1em",
),
rx.cond(NewMedicineState.show_med_add_form, new_medicine_form()),
rx.cond(
NewMedicineState.updated_uuid,
taken_form(),
rx.text(
"Scan the Med",
),
),
on_mount=NewMedicineState.start_scan,
)

20
reflex_ipad/pages/meds.py Normal file
View File

@@ -0,0 +1,20 @@
"""The meds page."""
from reflex_ipad.templates import template
import reflex as rx
@template(route="/medicine", title="Medikamente")
def dashboard() -> rx.Component:
"""The dashboard page.
Returns:
The UI for the dashboard page.
"""
return rx.vstack(
rx.heading("Medikamente", font_size="3em"),
rx.text("Heute schon genommen?"),
rx.text(
"Scan the Med",
),
)

View File

@@ -0,0 +1,22 @@
"""The settings page."""
from reflex_ipad.templates import template
import reflex as rx
@template(route="/settings", title="Settings")
def settings() -> rx.Component:
"""The settings page.
Returns:
The UI for the settings page.
"""
return rx.vstack(
rx.heading("Settings", font_size="3em"),
rx.text("Welcome to Reflex!"),
rx.text(
"You can edit this page in ",
rx.code("{your_app}/pages/settings.py"),
),
)

View File

@@ -0,0 +1,17 @@
"""Welcome to Reflex!."""
from reflex_ipad import styles
# Import all the pages.
from reflex_ipad.pages import *
from reflex_ipad import api
from reflex_ipad.models import *
import reflex as rx
# Create the app and compile it.
app = rx.App(style=styles.base_style)
api.register_at(app)
app.compile()

9
reflex_ipad/state.py Normal file
View File

@@ -0,0 +1,9 @@
import reflex as rx
import time
import datetime
import asyncio
from reflex_ipad.models import *
class State(rx.State):
"""Define your app state here."""
pass

62
reflex_ipad/styles.py Normal file
View File

@@ -0,0 +1,62 @@
"""Styles for the app."""
import reflex as rx
border_radius = "0.375rem"
box_shadow = "0px 0px 0px 1px rgba(84, 82, 95, 0.14)"
border = "1px solid #F4F3F6"
text_color = "black"
accent_text_color = "#1A1060"
accent_color = "#F5EFFE"
hover_accent_color = {"_hover": {"color": accent_color}}
hover_accent_bg = {"_hover": {"bg": accent_color}}
content_width_vw = "90vw"
sidebar_width = "20em"
template_page_style = {"padding_top": "5em", "padding_x": ["auto", "2em"], "flex": "1"}
template_content_style = {
"align_items": "flex-start",
"box_shadow": box_shadow,
"border_radius": border_radius,
"padding": "1em",
"margin_bottom": "2em",
}
link_style = {
"color": text_color,
"text_decoration": "none",
**hover_accent_color,
}
overlapping_button_style = {
"background_color": "white",
"border": border,
"border_radius": border_radius,
}
base_style = {
rx.MenuButton: {
"width": "3em",
"height": "3em",
**overlapping_button_style,
},
rx.MenuItem: hover_accent_bg,
}
markdown_style = {
"code": lambda text: rx.code(text, color="#1F1944", bg="#EAE4FD"),
"a": lambda text, **props: rx.link(
text,
**props,
font_weight="bold",
color="#03030B",
text_decoration="underline",
text_decoration_color="#AD9BF8",
_hover={
"color": "#AD9BF8",
"text_decoration": "underline",
"text_decoration_color": "#03030B",
},
),
}

View File

@@ -0,0 +1 @@
from .template import template

View File

@@ -0,0 +1,127 @@
"""Common templates used between pages in the app."""
from __future__ import annotations
from reflex_ipad import styles
from reflex_ipad.components.sidebar import sidebar
from typing import Callable
import reflex as rx
# Meta tags for the app.
default_meta = [
{
"name": "viewport",
"content": "width=device-width, shrink-to-fit=no, initial-scale=1",
},
]
def menu_button() -> rx.Component:
"""The menu button on the top right of the page.
Returns:
The menu button component.
"""
from reflex.page import get_decorated_pages
return rx.box(
rx.menu(
rx.menu_button(
rx.icon(
tag="hamburger",
size="4em",
color=styles.text_color,
),
),
rx.menu_list(
*[
rx.menu_item(
rx.link(
page["title"],
href=page["route"],
width="100%",
)
)
for page in get_decorated_pages()
],
rx.menu_divider(),
rx.menu_item(
rx.link("About", href="https://github.com/reflex-dev", width="100%")
),
rx.menu_item(
rx.link("Contact", href="mailto:founders@=reflex.dev", width="100%")
),
),
),
position="fixed",
right="1.5em",
top="1.5em",
z_index="500",
)
def template(
route: str | None = None,
title: str | None = None,
image: str | None = None,
description: str | None = None,
meta: str | None = None,
script_tags: list[rx.Component] | None = None,
on_load: rx.event.EventHandler | list[rx.event.EventHandler] | None = None,
) -> Callable[[Callable[[], rx.Component]], rx.Component]:
"""The template for each page of the app.
Args:
route: The route to reach the page.
title: The title of the page.
image: The favicon of the page.
description: The description of the page.
meta: Additionnal meta to add to the page.
on_load: The event handler(s) called when the page load.
script_tags: Scripts to attach to the page.
Returns:
The template with the page content.
"""
def decorator(page_content: Callable[[], rx.Component]) -> rx.Component:
"""The template for each page of the app.
Args:
page_content: The content of the page.
Returns:
The template with the page content.
"""
# Get the meta tags for the page.
all_meta = [*default_meta, *(meta or [])]
@rx.page(
route=route,
title=title,
image=image,
description=description,
meta=all_meta,
script_tags=script_tags,
on_load=on_load,
)
def templated_page():
return rx.hstack(
sidebar(),
rx.box(
rx.box(
page_content(),
**styles.template_content_style,
),
**styles.template_page_style,
),
menu_button(),
align_items="flex-start",
transition="left 0.5s, width 0.5s",
position="relative",
)
return templated_page
return decorator

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
reflex==0.3.7
croniter==2.0.1

6
rxconfig.py Normal file
View File

@@ -0,0 +1,6 @@
import reflex as rx
config = rx.Config(
app_name="reflex_ipad",
db_url="sqlite:///reflex.db",
)