diff --git a/.env b/.env new file mode 100644 index 00000000..2dc9251f --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=sqlite://db.sqlite \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 4a4a632e..4f23e85b 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -2,11 +2,12 @@ name: Deploy on: push: branches: [main] + workflow_dispatch: {} jobs: deploy: name: Deploy - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -16,14 +17,26 @@ jobs: targets: aarch64-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu - run: sudo apt install -y gcc-aarch64-linux-gnu - - uses: webfactory/ssh-agent@v0.9.0 + run: sudo apt update && sudo apt install -y gcc-aarch64-linux-gnu + + - uses: actions/setup-node@v4 with: - ssh-private-key: ${{ secrets.SSH_KEY }} - - name: ssh-keyscan + node-version: 23 + - run: npm ci + - run: npm run build:styles.min + + - name: Setup SSH key run: | - mkdir -p ~/.ssh - ssh-keyscan wlsd.lightandsound.design > ~/.ssh/known_hosts + mkdir ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.ROOT_SSH_PRIVKEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan beta.lightandsound.design > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + - name: deploy - run: scripts/deploy.sh ec2-user@wlsd.lightandsound.design + run: scripts/deploy.sh ec2-user@beta.lightandsound.design + env: + SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }} + SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }} + SQLX_OFFLINE: true diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e510b0d1..cf06ed16 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,15 +1,15 @@ name: Test on: pull_request: - push: - branches: ["*"] jobs: test: name: Test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -18,19 +18,34 @@ jobs: rustfmt: name: Format - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo fmt --check + prettier: + name: Format frontend + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + - run: npm ci + - run: npm run format:check + clippy: name: Lint - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -39,9 +54,11 @@ jobs: cross: name: Cross-compile - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 permissions: contents: read + env: + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable @@ -49,5 +66,5 @@ jobs: targets: aarch64-unknown-linux-gnu - uses: Swatinem/rust-cache@v2 - name: install gcc-aarch64-linux-gnu - run: sudo apt install -y gcc-aarch64-linux-gnu + run: sudo apt update && sudo apt install -y gcc-aarch64-linux-gnu - run: cargo build --target aarch64-unknown-linux-gnu diff --git a/.gitignore b/.gitignore index 579ab3ae..bb128afd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ Cargo.lock *.sqlite *.sqlite-* +.DS_Store +node_modules +frontend/static/dist +deno.lock \ No newline at end of file diff --git a/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json b/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json new file mode 100644 index 00000000..17495cf9 --- /dev/null +++ b/.sqlx/query-0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "slug", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "flyer", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "guest_list_id", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "0d4d698b039ac95743392f44379bd1b8881297a06d0e0d333b065aec5d9da24e" +} diff --git a/.sqlx/query-134ea5dade59d0645c4dcb003f2a643dc07eb1b5ef514ea7db23cceebbd6a39f.json b/.sqlx/query-134ea5dade59d0645c4dcb003f2a643dc07eb1b5ef514ea7db23cceebbd6a39f.json new file mode 100644 index 00000000..8269c68e --- /dev/null +++ b/.sqlx/query-134ea5dade59d0645c4dcb003f2a643dc07eb1b5ef514ea7db23cceebbd6a39f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO tickets (name, description)\n VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "134ea5dade59d0645c4dcb003f2a643dc07eb1b5ef514ea7db23cceebbd6a39f" +} diff --git a/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json b/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json new file mode 100644 index 00000000..11a18a57 --- /dev/null +++ b/.sqlx/query-177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM notifications WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "177c4b9cc7901a3b906e5969b86b1c11e6acbfb8e86e98f197d7333030b17964" +} diff --git a/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json b/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json new file mode 100644 index 00000000..85e3b92c --- /dev/null +++ b/.sqlx/query-1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM list_members\n WHERE list_id = ? AND email = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "1a2b6f7bcaf1ca974905553befb8099d674224915857ad5ff1e73c5f3aad1522" +} diff --git a/.sqlx/query-1cde3c03cd3dcbabf88853a96d2087f2ca9d3b238c492029ef7df45e3715b6d3.json b/.sqlx/query-1cde3c03cd3dcbabf88853a96d2087f2ca9d3b238c492029ef7df45e3715b6d3.json new file mode 100644 index 00000000..0badfb2d --- /dev/null +++ b/.sqlx/query-1cde3c03cd3dcbabf88853a96d2087f2ca9d3b238c492029ef7df45e3715b6d3.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM tickets", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "1cde3c03cd3dcbabf88853a96d2087f2ca9d3b238c492029ef7df45e3715b6d3" +} diff --git a/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json b/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json new file mode 100644 index 00000000..2c185e54 --- /dev/null +++ b/.sqlx/query-20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts WHERE url = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "20429d381a684d72a57d38616f4c695548575d341037b64a7f010d47f7d7fb1a" +} diff --git a/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json b/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json new file mode 100644 index 00000000..61e98930 --- /dev/null +++ b/.sqlx/query-2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM notifications", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 5, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + true, + false, + false, + false, + false + ] + }, + "hash": "2199d77ace9dfa4cd493fe218b45d3b833291a4e671d0205317496af255093d1" +} diff --git a/.sqlx/query-25224228e1569bdc1fa48f501b60f36501b33787b490276cfc1c5f7380a16fec.json b/.sqlx/query-25224228e1569bdc1fa48f501b60f36501b33787b490276cfc1c5f7380a16fec.json new file mode 100644 index 00000000..90f2b9b6 --- /dev/null +++ b/.sqlx/query-25224228e1569bdc1fa48f501b60f36501b33787b490276cfc1c5f7380a16fec.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM rsvps WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "25224228e1569bdc1fa48f501b60f36501b33787b490276cfc1c5f7380a16fec" +} diff --git a/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json b/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json new file mode 100644 index 00000000..891c848c --- /dev/null +++ b/.sqlx/query-290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users\n (first_name, last_name, email)\n VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "290377c580379dc3adb59b375cd511b0700ef9c11eac0204c2c0aa5c04929270" +} diff --git a/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json b/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json new file mode 100644 index 00000000..a6d64ce2 --- /dev/null +++ b/.sqlx/query-298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO posts\n (title, url, author, content)\n VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "298119339e5e31febd2ab2c9c8a6abd83f637c2feb205c3f1f957f410ca6b1e4" +} diff --git a/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json new file mode 100644 index 00000000..7395c683 --- /dev/null +++ b/.sqlx/query-3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM users WHERE email = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false + ] + }, + "hash": "3e4e5c4be44ef130a6e886ed815bdf3e8d3d3f61e17713aa10f9912522cd84ca" +} diff --git a/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json new file mode 100644 index 00000000..08996f60 --- /dev/null +++ b/.sqlx/query-40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "40f493f5a0f47f170c97e2f60b972a5ea03360acc445bbc9889a30500802da8a" +} diff --git a/.sqlx/query-44ebca9a5e86368ad5484b7c401f1d650616e9a5b042c16023bbf3082f97726d.json b/.sqlx/query-44ebca9a5e86368ad5484b7c401f1d650616e9a5b042c16023bbf3082f97726d.json new file mode 100644 index 00000000..53a75712 --- /dev/null +++ b/.sqlx/query-44ebca9a5e86368ad5484b7c401f1d650616e9a5b042c16023bbf3082f97726d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE events\n SET title = ?,\n slug = ?,\n description = ?,\n flyer = ?,\n start = ?,\n end = ?,\n guest_list_id = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "44ebca9a5e86368ad5484b7c401f1d650616e9a5b042c16023bbf3082f97726d" +} diff --git a/.sqlx/query-45bff1a48faf91f32be4dd9faf64568255b5cda332e8f8037d326d89c7877ba8.json b/.sqlx/query-45bff1a48faf91f32be4dd9faf64568255b5cda332e8f8037d326d89c7877ba8.json new file mode 100644 index 00000000..3eb20474 --- /dev/null +++ b/.sqlx/query-45bff1a48faf91f32be4dd9faf64568255b5cda332e8f8037d326d89c7877ba8.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM rsvps", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "ticket_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "transaction_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "promo_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "refund_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "arrived_at", + "ordinal": 9, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "45bff1a48faf91f32be4dd9faf64568255b5cda332e8f8037d326d89c7877ba8" +} diff --git a/.sqlx/query-4fdb2f717894d723eed3b3bac76e722d52326582a7e42b408472afd067a5aafb.json b/.sqlx/query-4fdb2f717894d723eed3b3bac76e722d52326582a7e42b408472afd067a5aafb.json new file mode 100644 index 00000000..094c57ed --- /dev/null +++ b/.sqlx/query-4fdb2f717894d723eed3b3bac76e722d52326582a7e42b408472afd067a5aafb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE rsvps\n SET user_id = ?,\n event_id = ?,\n ticket_id = ?,\n transaction_id = ?,\n promo_id = ?,\n refund_id = ?,\n arrived_at = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "4fdb2f717894d723eed3b3bac76e722d52326582a7e42b408472afd067a5aafb" +} diff --git a/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json new file mode 100644 index 00000000..2298a135 --- /dev/null +++ b/.sqlx/query-5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5931fda3449b5465ff6555597b812b69b79de2bbb75428543f0170d31f54226c" +} diff --git a/.sqlx/query-599d9b89df25c7935ef5339772e67c04132fefdf176df7cae9f326e9a5ce957e.json b/.sqlx/query-599d9b89df25c7935ef5339772e67c04132fefdf176df7cae9f326e9a5ce957e.json new file mode 100644 index 00000000..aabed255 --- /dev/null +++ b/.sqlx/query-599d9b89df25c7935ef5339772e67c04132fefdf176df7cae9f326e9a5ce957e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO rsvps\n (user_id, event_id, ticket_id, transaction_id, promo_id, refund_id)\n VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "599d9b89df25c7935ef5339772e67c04132fefdf176df7cae9f326e9a5ce957e" +} diff --git a/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json b/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json new file mode 100644 index 00000000..fd52d0ae --- /dev/null +++ b/.sqlx/query-5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO login_tokens (email, token) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5c977135ebf0de7e42d2974986ead9cb7aec768dd47520f012a7efea6ddd0092" +} diff --git a/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json b/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json new file mode 100644 index 00000000..319172e7 --- /dev/null +++ b/.sqlx/query-69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM notifications WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 5, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + false, + false + ] + }, + "hash": "69515bd8402e2b5fe578acb5a0571e3db9f1557cbf3c38e744d68c00ac45cda7" +} diff --git a/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json b/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json new file mode 100644 index 00000000..830e60f0 --- /dev/null +++ b/.sqlx/query-78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM emails WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "post_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "user_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 10, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "78d415f6740101e25b90d73b877e1e110d9509ca4686ab431edaa36e5cd5266f" +} diff --git a/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json new file mode 100644 index 00000000..6aa292f3 --- /dev/null +++ b/.sqlx/query-79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM posts WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "79301b44b77802e0096efd73b1e9adac27b27a3cf7bf853af3a9f130b1684d91" +} diff --git a/.sqlx/query-7abf036127f3202794dc45a450365041925daff19313e4ef1ad09bc460ed991d.json b/.sqlx/query-7abf036127f3202794dc45a450365041925daff19313e4ef1ad09bc460ed991d.json new file mode 100644 index 00000000..c84977ec --- /dev/null +++ b/.sqlx/query-7abf036127f3202794dc45a450365041925daff19313e4ef1ad09bc460ed991d.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM tickets WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "7abf036127f3202794dc45a450365041925daff19313e4ef1ad09bc460ed991d" +} diff --git a/.sqlx/query-7b37a8021c049847732a467805a001fba10f564249048e72623f644400b724a7.json b/.sqlx/query-7b37a8021c049847732a467805a001fba10f564249048e72623f644400b724a7.json new file mode 100644 index 00000000..716b3a61 --- /dev/null +++ b/.sqlx/query-7b37a8021c049847732a467805a001fba10f564249048e72623f644400b724a7.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM rsvps WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "ticket_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "transaction_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "promo_id", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "refund_id", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "created_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "arrived_at", + "ordinal": 9, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false, + false, + true + ] + }, + "hash": "7b37a8021c049847732a467805a001fba10f564249048e72623f644400b724a7" +} diff --git a/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json new file mode 100644 index 00000000..19adbd4b --- /dev/null +++ b/.sqlx/query-7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT *\n FROM lists\n WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "7fcbeef9dea50c25ceddf32c9f817f8527d7bca1554989625aee8fba037fc032" +} diff --git a/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json new file mode 100644 index 00000000..912ac4bd --- /dev/null +++ b/.sqlx/query-83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET opened_at = CURRENT_TIMESTAMP\n WHERE id = ? AND opened_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "83f2fdce3d64410e3182cd4da61717c30574e0f533c11529aa87ee9722c90dc2" +} diff --git a/.sqlx/query-8423697735ffe65b552f5741c9ec3d23651ab565bfd509756f87c5a7fd55f703.json b/.sqlx/query-8423697735ffe65b552f5741c9ec3d23651ab565bfd509756f87c5a7fd55f703.json new file mode 100644 index 00000000..fec309cd --- /dev/null +++ b/.sqlx/query-8423697735ffe65b552f5741c9ec3d23651ab565bfd509756f87c5a7fd55f703.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO notifications (event_id, name, content)\n VALUES (?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "8423697735ffe65b552f5741c9ec3d23651ab565bfd509756f87c5a7fd55f703" +} diff --git a/.sqlx/query-84594a743c582ebb933ac2d5df323df7c700ee0654efe1d1bcd0148a543c36d0.json b/.sqlx/query-84594a743c582ebb933ac2d5df323df7c700ee0654efe1d1bcd0148a543c36d0.json new file mode 100644 index 00000000..ca175a08 --- /dev/null +++ b/.sqlx/query-84594a743c582ebb933ac2d5df323df7c700ee0654efe1d1bcd0148a543c36d0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO events\n (title, slug, description, flyer, start, end, guest_list_id)\n VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "84594a743c582ebb933ac2d5df323df7c700ee0654efe1d1bcd0148a543c36d0" +} diff --git a/.sqlx/query-86f116c91dbed0177addf1a4f1727c2d9421438894ad7966ff38dc16b28c1954.json b/.sqlx/query-86f116c91dbed0177addf1a4f1727c2d9421438894ad7966ff38dc16b28c1954.json new file mode 100644 index 00000000..626364ef --- /dev/null +++ b/.sqlx/query-86f116c91dbed0177addf1a4f1727c2d9421438894ad7966ff38dc16b28c1954.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE notifications\n SET event_id = ?, name = ?, content = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "86f116c91dbed0177addf1a4f1727c2d9421438894ad7966ff38dc16b28c1954" +} diff --git a/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json new file mode 100644 index 00000000..00b43b65 --- /dev/null +++ b/.sqlx/query-8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO lists\n (name, description)\n VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8c9838143d95813046f750a6056d3fc34ee65fb399813097facc87082c89b26e" +} diff --git a/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json b/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json new file mode 100644 index 00000000..579c49ee --- /dev/null +++ b/.sqlx/query-92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM user_roles WHERE user_id = ? AND role = ?", + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "role", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 2, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "92951c98122de20b90bd7cfe5feb963b7dc2d5cf7a033c33f7d1587858387809" +} diff --git a/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json b/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json new file mode 100644 index 00000000..af39baf2 --- /dev/null +++ b/.sqlx/query-9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455.json @@ -0,0 +1,74 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM events WHERE id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + }, + { + "name": "slug", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "flyer", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "start", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "end", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "guest_list_id", + "ordinal": 9, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "9546f551138edd30d2c27560d55c41d13da40b3e927ab0eca381ab3af2b81455" +} diff --git a/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json new file mode 100644 index 00000000..74694359 --- /dev/null +++ b/.sqlx/query-964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE lists\n SET name = ?, description = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "964a241138128189d33b7eae8984d60777cf278c40214dcb62b6e9c1bb016675" +} diff --git a/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json new file mode 100644 index 00000000..922e50a6 --- /dev/null +++ b/.sqlx/query-9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.*\n FROM session_tokens t\n JOIN users u on u.id = t.user_id\n WHERE token = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false + ] + }, + "hash": "9bd89e00e6b5e5542e2026ebfa908ddb227c6aeb37b58e228bb5f382c82d36c2" +} diff --git a/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json new file mode 100644 index 00000000..0e6e7132 --- /dev/null +++ b/.sqlx/query-9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE emails SET sent_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9c534cdec87dec71efe8ab59329e6f85f32411793dd3e618dd830a8365159a94" +} diff --git a/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json b/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json new file mode 100644 index 00000000..19d38a55 --- /dev/null +++ b/.sqlx/query-9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.id as \"id!\", u.first_name as \"first_name!\", u.last_name as \"last_name!\", u.email as \"email!\", u.created_at as \"created_at!\"\n FROM login_tokens t\n JOIN users u on u.email = t.email\n WHERE token = ?", + "describe": { + "columns": [ + { + "name": "id!", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "first_name!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name!", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "email!", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at!", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + true, + true + ] + }, + "hash": "9df006c4a5af40d057a1d1617c552f2b897888afe7efc321913db83752287e5d" +} diff --git a/.sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json b/.sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json new file mode 100644 index 00000000..b2692b07 --- /dev/null +++ b/.sqlx/query-b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT e.email, u.first_name, u.last_name\n FROM list_members e\n LEFT JOIN users u ON u.email = e.email\n WHERE e.list_id = ?\n ORDER BY e.created_at", + "describe": { + "columns": [ + { + "name": "email", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "first_name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "last_name", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true + ] + }, + "hash": "b073958ea1a74ed2980f22790b5b08e10e1eca4bea5beaa2be07ba22c99377e7" +} diff --git a/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json b/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json new file mode 100644 index 00000000..d96d336d --- /dev/null +++ b/.sqlx/query-b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO emails (kind, address) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b8b5e3965c9cb3c03a419aaad5cba6c41b7110b8ca8bc046f1cf84a364aa4607" +} diff --git a/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json b/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json new file mode 100644 index 00000000..76e99ccd --- /dev/null +++ b/.sqlx/query-bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT email FROM login_tokens WHERE token = ?", + "describe": { + "columns": [ + { + "name": "email", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "bea5c734831d573592387f98f6106b0d244cda9060eb13f32f2aba048af0e3c2" +} diff --git a/.sqlx/query-c1f55a573f08ea117dc86150cc3660c2fe4df79b4760747614de110c4e432201.json b/.sqlx/query-c1f55a573f08ea117dc86150cc3660c2fe4df79b4760747614de110c4e432201.json new file mode 100644 index 00000000..e22ad86a --- /dev/null +++ b/.sqlx/query-c1f55a573f08ea117dc86150cc3660c2fe4df79b4760747614de110c4e432201.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE tickets\n SET name = ?, description = ?\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "c1f55a573f08ea117dc86150cc3660c2fe4df79b4760747614de110c4e432201" +} diff --git a/.sqlx/query-c454231695c47e3f72cd8653dd07d5ac6b4b6b33589321a3e3464e83ad25f36d.json b/.sqlx/query-c454231695c47e3f72cd8653dd07d5ac6b4b6b33589321a3e3464e83ad25f36d.json new file mode 100644 index 00000000..56ed06db --- /dev/null +++ b/.sqlx/query-c454231695c47e3f72cd8653dd07d5ac6b4b6b33589321a3e3464e83ad25f36d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM tickets WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "c454231695c47e3f72cd8653dd07d5ac6b4b6b33589321a3e3464e83ad25f36d" +} diff --git a/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json b/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json new file mode 100644 index 00000000..1d67a0fe --- /dev/null +++ b/.sqlx/query-cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE posts\n SET title = ?,\n url = ?,\n author = ?,\n content = ?,\n updated_at = CURRENT_TIMESTAMP\n WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "cb18b1eec09f19206c0fac1d99edba6203017212c67c7f0ec98b8ed7227f84df" +} diff --git a/.sqlx/query-ccf959f710c5dad0c29e1cf91861d41eaa00600408ad3a6f6160af6787103592.json b/.sqlx/query-ccf959f710c5dad0c29e1cf91861d41eaa00600408ad3a6f6160af6787103592.json new file mode 100644 index 00000000..6852d610 --- /dev/null +++ b/.sqlx/query-ccf959f710c5dad0c29e1cf91861d41eaa00600408ad3a6f6160af6787103592.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM user_roles WHERE user_id = ? AND role = ? OR role = ?", + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "role", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 2, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "ccf959f710c5dad0c29e1cf91861d41eaa00600408ad3a6f6160af6787103592" +} diff --git a/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json new file mode 100644 index 00000000..6437e012 --- /dev/null +++ b/.sqlx/query-ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM lists", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 3, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 4, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ce644c8de2b423f5e8c9079ea7eb3175f5dea2d8b7c407b17a306e5166aab3e4" +} diff --git a/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json b/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json new file mode 100644 index 00000000..7b64fd90 --- /dev/null +++ b/.sqlx/query-d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM emails\n WHERE address = ? AND post_id = ? AND list_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "kind", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "address", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "post_id", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "list_id", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "error", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Datetime" + }, + { + "name": "sent_at", + "ordinal": 7, + "type_info": "Datetime" + }, + { + "name": "opened_at", + "ordinal": 8, + "type_info": "Datetime" + }, + { + "name": "user_id", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "event_id", + "ordinal": 10, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + true, + true, + true, + true + ] + }, + "hash": "d5b31cf64e5569aefee09f726593c1bba2925c2bf3eececa5159caac1ff36bf2" +} diff --git a/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json new file mode 100644 index 00000000..a2dc24dd --- /dev/null +++ b/.sqlx/query-df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT * FROM posts ORDER BY updated_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "url", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "author", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "content", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Datetime" + }, + { + "name": "updated_at", + "ordinal": 6, + "type_info": "Datetime" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "df6e050c7cf06ac944f01151a07584d3ddc0c6b432b7c518cb2a8140e9a08613" +} diff --git a/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json new file mode 100644 index 00000000..10c970d6 --- /dev/null +++ b/.sqlx/query-e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM events WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e2352164237b90647e57f3d212ee24456e16e5a12e033a0f6ce315c9ce838a4e" +} diff --git a/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json b/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json new file mode 100644 index 00000000..743d9a4b --- /dev/null +++ b/.sqlx/query-e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "e39bcb46ac4484d8ebdcf2cdbe2cbc5f6fd57c905dd9530573713dc9ca96c783" +} diff --git a/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json b/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json new file mode 100644 index 00000000..52e7ca21 --- /dev/null +++ b/.sqlx/query-f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_roles (user_id, role) VALUES (?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "f8e3598122a2bbdb663039273aae0416c5dd63be2150bf80387552313e9d087a" +} diff --git a/.swp b/.swp new file mode 100644 index 00000000..386575db Binary files /dev/null and b/.swp differ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..b502e78a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "bradlc.vscode-tailwindcss", + "rust-lang.rust-analyzer", + "esbenp.prettier-vscode", + "samuelcolvin.jinjahtml", + "dotenv.dotenv-vscode", + "tamasfe.even-better-toml" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..bf549e0c --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jinja-html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "files.associations": { + "*.css": "tailwindcss", + "*.html": "jinja-html" + }, + "prettier.configPath": "frontend/prettier.config.cjs", + "prettier.requireConfig": true, + "cmake.ignoreCMakeListsMissing": true, + "cmake.configureOnOpen": false, + "cmake.enableAutomaticKitScan": false, + "cmake.sourceDirectory": "${workspaceFolder}/nonexistent" +} diff --git a/Cargo.toml b/Cargo.toml index 2edaac27..c8259407 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,29 +1,58 @@ [package] -name = "wlsd" +name = "lsd" version = "0.1.0" edition = "2021" [dependencies] -axum = { version = "0.7", default-features = false, features = ["query", "form", "matched-path"] } +askama = { version = "0.13.0", features = ["serde_json"] } +axum = { version = "0.8", default-features = false, features = [ + "query", + "form", + "json", +] } axum-server = { version = "0.7", features = ["tls-rustls"] } -axum-extra = { version = "0.9", features = ["cookie"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } -tera = "1" -sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } -lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "pool", "smtp-transport", "tokio1", "tokio1-rustls-tls", "serde"] } -tokio = { version = "1", features = ["rt-multi-thread", "fs", "net", "sync", "macros"] } +axum-extra = { version = "0.10", features = ["cookie"] } +base64 = "0.22.1" +lettre = { version = "0.11", default-features = false, features = [ + "builder", + "hostname", + "pool", + "smtp-transport", + "tokio1", + "tokio1-rustls-tls", + "serde", +] } rustls = "0.23" rustls-acme = { version = "0.12", features = ["axum"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } +tokio = { version = "1", features = [ + "rt-multi-thread", + "fs", + "net", + "sync", + "macros", +] } +tower = "0.5.2" +tower-http = { version = "0.6", features = [ + "fs", + "request-id", + "trace", + "util", +] } anyhow = "1" -tracing = "0.1" -tracing-subscriber = "0.3" +chrono-tz = { version = "0.10", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } futures = "0.3" +rand = "0.8" serde = { version = "1", features = ["derive"] } toml = "0.8" -rand = "0.8" - +tracing = "0.1" +tracing-subscriber = "0.3" +uuid = { version = "1.16.0", features = ["v7"] } +askama_web = { version = "0.13.0", features = ["axum-0.8"] } +thiserror = "2.0.12" # Add a little optimization to debug builds [profile.dev] @@ -31,9 +60,3 @@ opt-level = 1 # And since they don't get recompiled often, fully optimize dependencies [profile.dev.package."*"] opt-level = 3 - -# Production build with more intense optimization -[profile.prod] -inherits = "release" -lto = true -codegen-units = 1 diff --git a/README.md b/README.md index d863e93d..30f89cf9 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# WLSD +# lightandsound.design Coming to you live. ## Setup Install a rust toolchain with [rustup.rs](https://rustup.rs): + ```sh curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo --version @@ -12,18 +13,41 @@ rustc --version ``` Clone the repo: + +```sh +git clone https://github.com/foltik/lsd +cd lsd +``` + +Create a .env file with `DATABASE_URL=sqlite://db.sqlite` + +Initialize dev database: + ```sh -git clone https://github.com/foltik/wlsd -cd wlsd +cargo install sqlx-cli --no-default-features --features sqlite +cargo sqlx database setup ``` To automatically recompile and rerun when you make changes, use `cargo-watch`: + ```sh cargo install cargo-watch cargo watch -x 'run config/dev.toml' ``` +Run Tailwind CLI to compile styles via `npm`. +You can optionally install the [livereload][https://www.npmjs.com/package/livereload] extension to reload the page on style changes. + +```sh +npm install +# Watch for changes to ./frontend/styles/main.css, start livereload server +npm run watch +# Build styles minified +npm run build:styles.min +``` + Use [mailtutan](https://github.com/mailtutan/mailtutan) for local testing of email functionality: + ```sh cargo install mailtutan mailtutan @@ -31,5 +55,5 @@ mailtutan ## Workflow -* Make commits in a separate branch, and open a PR against `main` -* When new commits land in `main`, a github action will automatically deploy the app to https://wlsd.foltz.io +- Make commits in a separate branch, and open a PR against `main` +- When new commits land in `main`, a github action will automatically deploy the app to https://beta.lightandsound.design diff --git a/askama.toml b/askama.toml new file mode 100644 index 00000000..8275a9a6 --- /dev/null +++ b/askama.toml @@ -0,0 +1,2 @@ +[general] +dirs = ["frontend/templates"] diff --git a/config/dev.toml b/config/dev.toml index abea9524..deff937c 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -1,11 +1,16 @@ [app] +domain = "localhost" url = "https://localhost:4433" -db = "db.sqlite" +tz = "America/New_York" + +[db] +file = "db.sqlite" +seed_data = "config/seed_data.toml" [net] http_addr = "[::]:8080" https_addr = "[::]:4433" [email] -addr = "smtp://localhost:1025" -from = "WLSD " +smtp_addr = "smtp://localhost:1025" +from = "Light and Sound Design " diff --git a/config/prod.toml b/config/prod.toml index 2e966456..2916f3e5 100644 --- a/config/prod.toml +++ b/config/prod.toml @@ -1,17 +1,24 @@ [app] -url = "https://wlsd.lightandsound.design" -db = "db.sqlite" +domain = "beta.lightandsound.design" +url = "https://beta.lightandsound.design" +tz = "America/New_York" + +[db] +file = "db.sqlite" [net] http_addr = "[::]:80" https_addr = "[::]:443" [acme] -domain = "wlsd.lightandsound.design" -email = "studio249@foltz.io" +domain = "beta.lightandsound.design" +email = "studio@beta.lightandsound.design" dir = "acme" prod = true [email] -addr = "smtp://localhost:1080" -from = "WLSD " +smtp_addr = "smtp://email-smtp.us-east-1.amazonaws.com?tls=required" +smtp_username = "$SMTP_USERNAME" +smtp_password = "$SMTP_PASSWORD" +from = "Light and Sound Design " +ratelimit = 10 diff --git a/config/seed_data.toml b/config/seed_data.toml new file mode 100644 index 00000000..066ff876 --- /dev/null +++ b/config/seed_data.toml @@ -0,0 +1,14 @@ +[[users]] +email = "test@test.com" +first_name = "Test" +last_name = "Testington" + +[[user_roles]] +user_id = 1 +role = "admin" +created_at="2025-04-04T16:03:35" + +[[user_roles]] +user_id = 1 +role = "writer" +created_at="2025-04-04T16:03:35" \ No newline at end of file diff --git a/frontend/prettier.config.cjs b/frontend/prettier.config.cjs new file mode 100644 index 00000000..63f9b4a8 --- /dev/null +++ b/frontend/prettier.config.cjs @@ -0,0 +1,16 @@ +const config = { + plugins: [ + require.resolve("prettier-plugin-jinja-template"), + require.resolve("prettier-plugin-tailwindcss"), + ], + overrides: [ + { + files: ["*.html"], + options: { + parser: "jinja-template", + }, + }, + ], +}; + +module.exports = config; diff --git a/assets/favicon.ico b/frontend/static/favicon.ico similarity index 100% rename from assets/favicon.ico rename to frontend/static/favicon.ico diff --git a/frontend/static/uploads/images/events/event_1749265279.d68ca145.png b/frontend/static/uploads/images/events/event_1749265279.d68ca145.png new file mode 100644 index 00000000..78a85d1c Binary files /dev/null and b/frontend/static/uploads/images/events/event_1749265279.d68ca145.png differ diff --git a/frontend/styles/auth/login.css b/frontend/styles/auth/login.css new file mode 100644 index 00000000..6c7c2706 --- /dev/null +++ b/frontend/styles/auth/login.css @@ -0,0 +1,9 @@ +#auth\/login { + @apply flex flex-col items-center p-6; + > .container { + @apply mt-12 max-w-sm border border-neutral-900 bg-neutral-900/20 p-6; + h1 { + @apply mb-4 border-b border-neutral-800 pb-4 text-2xl; + } + } +} diff --git a/frontend/styles/auth/register.css b/frontend/styles/auth/register.css new file mode 100644 index 00000000..590faab4 --- /dev/null +++ b/frontend/styles/auth/register.css @@ -0,0 +1,9 @@ +#auth\/register { + @apply flex flex-col items-center p-6; + > .container { + @apply mt-12 max-w-sm border border-neutral-900 bg-neutral-900/20 p-6; + h1 { + @apply mb-4 border-b border-neutral-800 pb-4 text-2xl; + } + } +} diff --git a/frontend/styles/events/create.css b/frontend/styles/events/create.css new file mode 100644 index 00000000..d1fb8cca --- /dev/null +++ b/frontend/styles/events/create.css @@ -0,0 +1,3 @@ +#events\/create { + @apply mx-auto flex w-full max-w-2xl flex-col; +} diff --git a/frontend/styles/events/edit.css b/frontend/styles/events/edit.css new file mode 100644 index 00000000..ffd96310 --- /dev/null +++ b/frontend/styles/events/edit.css @@ -0,0 +1,86 @@ +#events\/edit { + @apply mx-auto flex w-full max-w-2xl flex-col gap-6 p-8; + + h1 { + @apply text-lsd-white mb-6 text-3xl font-bold; + } + + .current-cover-image { + @apply bg-lsd-white/5 border-lsd-white/10 mb-6 rounded-lg border p-6; + + h3 { + @apply text-lsd-white mb-4 text-lg font-semibold; + } + } + + .event-cover-preview { + @apply border-lsd-white/20 h-auto max-w-full rounded-md border; + max-height: 200px; + object-fit: cover; + } + + .field { + @apply mb-6; + + label { + @apply text-lsd-white mb-2 block text-sm font-medium; + } + + input, + textarea, + select { + @apply bg-lsd-white/5 border-lsd-white/10 w-full rounded-md border px-3 py-2; + @apply text-lsd-white placeholder-lsd-white/50; + @apply focus:ring-lsd-blue focus:border-transparent focus:ring-2 focus:outline-none; + } + + small { + @apply text-lsd-white/60 mt-1 block text-sm; + } + } + + h2 { + @apply text-lsd-white mt-8 mb-4 text-2xl font-bold; + } + + .ticket-row { + @apply bg-lsd-white/5 border-lsd-white/10 mb-4 rounded-lg border p-4; + + h4 { + @apply text-lsd-white mb-3 text-lg font-semibold; + } + } + + .remove-ticket { + @apply mt-3 cursor-pointer rounded border-0 bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700; + } + + #add-ticket { + @apply mb-6 cursor-pointer rounded border-0 bg-green-600 px-4 py-2 font-medium text-white hover:bg-green-700; + } + + form button[type="submit"] { + @apply bg-lsd-blue hover:bg-lsd-blue/80 mb-6 cursor-pointer rounded border-0 px-6 py-2 font-medium text-white; + } + + .actions { + @apply border-lsd-white/10 mt-8 flex flex-wrap items-center gap-4 border-t pt-6; + + button { + @apply inline-flex cursor-pointer items-center rounded-md border-0 px-4 py-2 font-medium transition-colors; + } + + .cancel-btn { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 text-lsd-white; + } + + form { + @apply ml-auto; + margin-top: 0 !important; + + button { + @apply bg-red-600 text-white hover:bg-red-700; + } + } + } +} diff --git a/frontend/styles/events/list.css b/frontend/styles/events/list.css new file mode 100644 index 00000000..2bd628a3 --- /dev/null +++ b/frontend/styles/events/list.css @@ -0,0 +1,58 @@ +#events\/list { + @apply mx-auto mt-8 flex w-full max-w-4xl flex-col p-8; + .title { + @apply mb-4 text-2xl; + } + .event a { + @apply hover:underline; + } + .header { + @apply mb-8 flex items-center justify-between; + + a { + @apply bg-lsd-blue hover:bg-lsd-blue/80 inline-flex items-center rounded-md px-4 py-2 font-medium text-white; + text-decoration: none; + } + } + + h1 { + @apply text-lsd-white text-3xl font-bold; + } + + .event { + @apply bg-lsd-white/5 border-lsd-white/10 hover:bg-lsd-white/10 mb-4 flex items-center overflow-hidden rounded-lg border transition-colors; + + a { + @apply text-lsd-white flex flex-grow; + text-decoration: none; + } + } + + .event-cover-thumbnail { + @apply border-lsd-white/10 h-16 w-24 flex-shrink-0 border-r object-cover; + } + + .event-info { + @apply flex flex-grow flex-col p-4; + } + + .event-date { + @apply text-lsd-white/70 mb-1 text-sm; + } + + .event-title { + @apply text-lsd-white font-semibold; + } + + .event-description { + @apply text-lsd-white/80 mt-1 line-clamp-2 text-sm; + } + + .event-actions { + @apply flex-shrink-0 p-4; + + a { + @apply rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700; + } + } +} diff --git a/frontend/styles/events/view.css b/frontend/styles/events/view.css new file mode 100644 index 00000000..a9724087 --- /dev/null +++ b/frontend/styles/events/view.css @@ -0,0 +1,98 @@ +#events\/view { + @apply mx-auto flex w-full max-w-2xl flex-col gap-6 p-8; + + h1 { + @apply text-lsd-white mb-6 text-3xl font-bold; + } + + .current-cover-image { + @apply bg-lsd-white/5 border-lsd-white/10 rounded-lg border p-6; + + h3 { + @apply text-lsd-white mb-4 text-lg font-semibold; + } + } + + .event-cover-preview { + @apply border-lsd-white/20 h-auto max-w-full rounded-md border; + max-height: 300px; + object-fit: cover; + } + + .event-details { + @apply bg-lsd-white/5 border-lsd-white/10 space-y-4 rounded-lg border p-6; + } + + .field-display { + @apply flex flex-col gap-2; + + label { + @apply text-lsd-white/70 text-sm font-medium tracking-wide uppercase; + } + + span { + @apply text-lsd-white; + } + + p { + @apply text-lsd-white leading-relaxed; + margin: 0; + } + } + + h2 { + @apply text-lsd-white mt-8 mb-4 text-2xl font-bold; + } + + .tickets-container { + @apply space-y-4; + } + + .ticket-row { + @apply bg-lsd-white/5 border-lsd-white/10 rounded-lg border p-4; + + h4 { + @apply text-lsd-white mb-3 text-lg font-semibold; + } + } + + .ticket-details { + @apply grid grid-cols-1 gap-4 md:grid-cols-3; + } + + .ticket-detail { + @apply flex flex-col gap-1; + + label { + @apply text-lsd-white/70 text-sm font-medium tracking-wide uppercase; + } + + span { + @apply text-lsd-white font-medium; + } + } + + .actions { + @apply border-lsd-white/10 mt-8 flex flex-wrap items-center gap-4 border-t pt-6; + + a { + @apply inline-flex items-center rounded-md px-4 py-2 font-medium transition-colors; + @apply bg-lsd-blue hover:bg-lsd-blue/80 text-white; + text-decoration: none; + + &:nth-child(2) { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 text-lsd-white; + } + } + + form { + @apply ml-auto; + margin-top: 0 !important; + } + + button { + @apply inline-flex items-center rounded-md px-4 py-2 font-medium transition-colors; + @apply cursor-pointer border-0 bg-red-600 text-white hover:bg-red-700; + } + } +} diff --git a/frontend/styles/extentions/form.css b/frontend/styles/extentions/form.css new file mode 100644 index 00000000..5189d2a9 --- /dev/null +++ b/frontend/styles/extentions/form.css @@ -0,0 +1,57 @@ +.ext\/form { + @apply flex flex-col; +} + +.ext\/form input, +.ext\/input { + @apply border-lsd-white/30 bg-lsd-black w-full rounded-md border px-3 py-2; + @apply focus:border-lsd-blue focus:outline-none; +} + +.ext\/form label, +.ext\/label { + @apply mb-2 block; +} + +.ext\/form textarea, +.ext\/textarea { + @apply bg-lsd-black h-[50vh] w-full flex-1 resize-y p-2; + @apply border-lsd-white/30 border; +} + +.ext\/form select, +.ext\/select { + @apply border-lsd-white/40 bg-lsd-black rounded-md border px-3 py-2; + + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background: url('data:image/svg+xml;utf8,') + no-repeat right 0.5rem center; + + background-size: 1.5rem 1.5rem; +} + +.ext\/form .field, +.ext\/field { + @apply mb-4 flex flex-col pb-4; +} + +.ext\/form button, +.ext\/button { + @apply bg-lsd-white/10 hover:bg-lsd-white/15 block cursor-pointer px-4 py-2; + @apply border-lsd-white/10 border; + @apply rounded-sm; + + &.\:icon { + @apply px-3; + } + + &.\:green { + @apply border-lsd-green/30 bg-lsd-green/20 hover:bg-lsd-green/30; + } + + &.\:red { + @apply border-lsd-red/30 bg-lsd-red/20 hover:bg-lsd-red/30; + } +} diff --git a/frontend/styles/fonts.css b/frontend/styles/fonts.css new file mode 100644 index 00000000..c32bdc32 --- /dev/null +++ b/frontend/styles/fonts.css @@ -0,0 +1,28 @@ +@font-face { + font-family: "DM Sans Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 1000; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/dm-sans:vf@latest/latin-wght-normal.woff2) + format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Poppins"; + font-style: normal; + font-display: swap; + font-weight: 700; + src: + url(https://cdn.jsdelivr.net/fontsource/fonts/poppins@latest/latin-700-normal.woff2) + format("woff2"), + url(https://cdn.jsdelivr.net/fontsource/fonts/poppins@latest/latin-700-normal.woff) + format("woff"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, + U+2215, U+FEFF, U+FFFD; +} diff --git a/frontend/styles/lists/edit.css b/frontend/styles/lists/edit.css new file mode 100644 index 00000000..50c61168 --- /dev/null +++ b/frontend/styles/lists/edit.css @@ -0,0 +1,9 @@ +#lists\/edit { + @apply flex flex-col items-center; + form { + @apply mt-8 mb-12 w-full max-w-xl; + textarea { + @apply min-h-52; + } + } +} diff --git a/frontend/styles/lists/signup.css b/frontend/styles/lists/signup.css new file mode 100644 index 00000000..01bc0e8d --- /dev/null +++ b/frontend/styles/lists/signup.css @@ -0,0 +1,9 @@ +#lists\/signup { + @apply flex w-full flex-col items-center; + .container { + @apply mt-12 w-full max-w-sm; + } + h2 { + @apply mb-2 text-xl font-bold; + } +} diff --git a/frontend/styles/lists/view.css b/frontend/styles/lists/view.css new file mode 100644 index 00000000..d67b3724 --- /dev/null +++ b/frontend/styles/lists/view.css @@ -0,0 +1,15 @@ +#lists\/view { + @apply mx-auto mt-8 flex max-w-4xl flex-col px-6; +} + +#lists\/view header { + @apply mb-8 flex items-end justify-between px-0 pb-4; + @apply border-b border-neutral-800; + .title { + @apply text-lg uppercase; + } +} + +#lists\/view .lists { + @apply text-lg underline hover:decoration-blue-500; +} diff --git a/frontend/styles/main.css b/frontend/styles/main.css new file mode 100644 index 00000000..85a859ed --- /dev/null +++ b/frontend/styles/main.css @@ -0,0 +1,78 @@ +@import "tailwindcss"; + +@import "./fonts.css"; + +@theme { + --font-sans: DM Sans Variable, sans-serif; + --font-display: Poppins, sans-serif; + /* CSS HEX */ + --color-lsd-black: #080504; + --color-lsd-white: #ebe3de; + --color-lsd-bright: #fcf3ee; + --color-lsd-blue: #8ec6ff; + --color-lsd-green: #6fd08c; + --color-lsd-red: #c36346; + --color-lsd-yellow: #eec643; +} + +/* + * TODO: Refactor styles into Tailwind utility classes + * Below styles are from page.tera.html + */ + +:root { + @apply bg-lsd-black text-lsd-white h-full w-full font-sans; +} + +body { + @apply min-h-full; +} + +body > header { + @apply border-lsd-white/10 flex flex-col items-center border-b px-6 py-4; + + nav { + @apply flex w-full max-w-6xl items-center gap-x-8; + } + nav .lsd { + @apply font-display text-lsd-bright font-bold; + } + nav .sep { + @apply h-4 border-r border-neutral-700; + } +} + +/** Extensions **/ + +@import "./extentions/form.css"; + +/** Modules **/ + +/* Auth */ + +@import "./auth/login.css"; +@import "./auth/register.css"; + +@import "./events/create.css"; +@import "./events/list.css"; +@import "./events/edit.css"; +@import "./events/view.css"; + +/* Lists */ + +@import "./lists/edit.css"; +@import "./lists/signup.css"; +@import "./lists/view.css"; + +/* Posts */ + +@import "./posts/edit.css"; +@import "./posts/list.css"; +@import "./posts/send.css"; +@import "./posts/view.css"; + +/* Tickets */ +@import "./tickets/create.css"; +@import "./tickets/edit.css"; +@import "./tickets/list.css"; +@import "./tickets/view.css"; diff --git a/frontend/styles/posts/edit.css b/frontend/styles/posts/edit.css new file mode 100644 index 00000000..5b8209aa --- /dev/null +++ b/frontend/styles/posts/edit.css @@ -0,0 +1,87 @@ +body:has(#posts\/edit) > header { + @apply hidden; +} + +#posts\/edit { + @apply w-full; +} + +#posts\/edit .editor { + @apply relative grid h-screen w-full; + grid-template-columns: 1fr 1fr 1fr auto; + + .navbar { + @apply sticky top-0 col-span-4 flex items-center py-2; + @apply border-lsd-white/10 gap-x-4 border-b px-8; + } + + .navbar .save { + @apply ml-auto flex items-center gap-x-4; + } + + .details { + @apply sticky col-span-1 h-full w-full min-w-sm px-4 py-4; + @apply border-lsd-white/10 border-l; + } + + .content { + @apply col-span-3 w-full overflow-y-auto px-8; + } + + .content .pell-wrapper { + @apply min-h-screen w-full; + @apply flex flex-col items-center; + } +} + +#posts\/edit .editor .content .pell { + @apply relative w-full max-w-3xl overflow-visible; + padding-bottom: 50%; + + .pell-actionbar { + @apply bg-lsd-black sticky top-0 mt-4 py-2.5; + @apply flex items-center gap-2.5; + + .pell-button { + @apply bg-lsd-white/10 hover:bg-lsd-white/20 h-8 w-10 p-1; + } + + .pell-button-selected { + @apply bg-lsd-white/20; + } + + #status { + @apply ml-auto text-sm; + @apply before:mr-1.5 before:content-["•"]; + } + + #status.unsaved { + @apply text-amber-400; + } + + #status.error { + @apply text-red-500; + } + } + + .pell-content { + @apply text-lg leading-relaxed; + @apply bg-lsd-white/5 grow overflow-y-auto p-8 focus:outline-none; + @apply mt-4; + a { + @apply decoration-lsd-blue underline; + } + } +} + +#posts\/edit .editor .content .resize { + @apply absolute top-0 bottom-0 z-50 w-4 cursor-ew-resize bg-transparent; + + &.left { + @apply left-0; + } + + &.right { + @apply right-0; + } +} diff --git a/frontend/styles/posts/list.css b/frontend/styles/posts/list.css new file mode 100644 index 00000000..74c57a78 --- /dev/null +++ b/frontend/styles/posts/list.css @@ -0,0 +1,37 @@ +#posts\/list { + @apply mx-auto mt-8 flex max-w-4xl flex-col px-6; +} + +#posts\/list header { + @apply mb-8 flex items-end justify-between px-0 pb-4; + @apply border-lsd-white/20 border-b; + .title { + @apply text-2xl font-bold; + } +} + +#posts\/list .post { + @apply mb-6 flex items-start justify-between pb-6; + + .title { + @apply mb-1 text-2xl font-bold; + a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + } + + .info { + @apply mb-4; + } + + .actions { + @apply flex; + a, + form { + @apply mr-2; + } + form { + @apply p-0; + } + } +} diff --git a/frontend/styles/posts/send.css b/frontend/styles/posts/send.css new file mode 100644 index 00000000..d9e74766 --- /dev/null +++ b/frontend/styles/posts/send.css @@ -0,0 +1,12 @@ +#posts\/send { + @apply mt-12 flex flex-col items-center; + form { + @apply border-lsd-white/10 w-full max-w-md rounded-md border p-6; + header { + @apply border-lsd-white/10 mb-6 w-full max-w-md border-b pb-4 text-lg font-bold; + } + .title { + @apply mb-4 text-2xl font-bold; + } + } +} diff --git a/frontend/styles/posts/view.css b/frontend/styles/posts/view.css new file mode 100644 index 00000000..ae081b02 --- /dev/null +++ b/frontend/styles/posts/view.css @@ -0,0 +1,34 @@ +#posts\/view { + @apply flex flex-col items-center px-6; + + > article { + @apply mt-6 lg:mt-12; + } + + .details { + @apply border-lsd-white/20 mb-6 border-b pb-6; + + .title { + @apply mb-1 text-4xl; + font-weight: 1000; + } + + .date { + @apply text-lsd-red; + } + } + + article { + @apply w-full max-w-2xl; + } + + .content { + @apply mb-16 text-lg leading-relaxed; + p a { + @apply decoration-lsd-blue hover:text-lsd-blue underline; + } + p img { + @apply mx-auto max-w-xl; + } + } +} diff --git a/frontend/templates/auth/login.html b/frontend/templates/auth/login.html new file mode 100644 index 00000000..1722048a --- /dev/null +++ b/frontend/templates/auth/login.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - login{% endblock title %} + +{% block content %} +
+
+

Login

+
+
+ + +
+
+ +
+
+
+
+{% endblock content %} diff --git a/frontend/templates/auth/register.html b/frontend/templates/auth/register.html new file mode 100644 index 00000000..12269900 --- /dev/null +++ b/frontend/templates/auth/register.html @@ -0,0 +1,25 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - register{% endblock title %} + +{% block content %} +
+
+

Register

+
+
+ + +
+ +
+ + +
+ + + +
+
+
+{% endblock content %} diff --git a/frontend/templates/emails/unsubscribe.html b/frontend/templates/emails/unsubscribe.html new file mode 100644 index 00000000..e1e73149 --- /dev/null +++ b/frontend/templates/emails/unsubscribe.html @@ -0,0 +1,24 @@ +{% extends "layout.html" %} +{% block title %}light and sound - Unsubscribe{% endblock title %} +{% block styles %} + {% call super() %} + +{% endblock styles %} + +{% block content %} +
+

Are you sure you want to unsubscribe from our mailing list?

+
+ +
+
+{% endblock %} diff --git a/frontend/templates/error.html b/frontend/templates/error.html new file mode 100644 index 00000000..97f2ca0b --- /dev/null +++ b/frontend/templates/error.html @@ -0,0 +1,6 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} +{% block content %} +

{{ message }}

+{% endblock content %} diff --git a/frontend/templates/events/create.html b/frontend/templates/events/create.html new file mode 100644 index 00000000..a5de8b92 --- /dev/null +++ b/frontend/templates/events/create.html @@ -0,0 +1,262 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - new event{% endblock title %} + +{% block content %} +
+

Let's Create an Event

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +

Ticket Types

+
+ {% for ticket in tickets %} +
+

{{ ticket.name }}

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ {% endfor %} +
+ + + +
+
+ + +{% endblock content %} diff --git a/frontend/templates/events/edit.html b/frontend/templates/events/edit.html new file mode 100644 index 00000000..98f90d48 --- /dev/null +++ b/frontend/templates/events/edit.html @@ -0,0 +1,284 @@ +{% extends "layout.html" %} + +{% block title %}Edit {{ event.title }}{% endblock title %} + +{% block content %} +
+

Update Event: {{ event.title }}

+ + + {% if event.flyer | has_value %} +
+

Current Cover Image:

+ {{ event.title }} cover image +
+ {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + Leave empty to keep current image +
+ + +

Ticket Types

+
+ {% for event_ticket in event_tickets %} +
+

Ticket ID: {{ event_ticket.ticket_id }}

+ +
+ + +
+
+ + +
+
+ + +
+ +
+ {% endfor %} +
+ + + +
+ +
+ + + +
+ +
+
+ +
+ + +{% endblock content %} diff --git a/frontend/templates/events/list.html b/frontend/templates/events/list.html new file mode 100644 index 00000000..85976eeb --- /dev/null +++ b/frontend/templates/events/list.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - events{% endblock title %} + +{% block content %} + +{% endblock content %} diff --git a/frontend/templates/events/view.html b/frontend/templates/events/view.html new file mode 100644 index 00000000..6e803080 --- /dev/null +++ b/frontend/templates/events/view.html @@ -0,0 +1,74 @@ +{% extends "layout.html" %} + +{% block title %}{{ event.title }}{% endblock title %} + +{% block content %} +
+

{{ event.title }}

+ + {% if event.flyer | has_value %} +
+

Cover Image:

+ {{ event.title }} cover image +
+ {% endif %} + +
+
+ + {{ event.title }} +
+ +
+ +

{{ event.description }}

+
+ +
+ + {{ event.start | format_datetime("%A, %B %d, %Y at %I:%M %p") }} +
+ +
+ + {{ event.slug }} +
+
+ + + {% if event_tickets | length > 0 %} +

Ticket Types

+
+ {% for event_ticket in event_tickets %} +
+

Ticket ID: {{ event_ticket.ticket_id }}

+
+
+ + ${{ event_ticket.price }} +
+
+ + {{ event_ticket.quantity }} available +
+
+ + {{ event_ticket.sort }} +
+
+
+ {% endfor %} +
+ {% endif %} + +
+ Edit Event + Back to Events + + +
+ +
+
+ +{% endblock content %} diff --git a/frontend/templates/index.html b/frontend/templates/index.html new file mode 100644 index 00000000..9a7026fb --- /dev/null +++ b/frontend/templates/index.html @@ -0,0 +1,56 @@ +{% extends "layout.html" %} + +{% block title %}light and sound{% endblock title %} + +{% block styles %} + +{% endblock styles %} + +{% block content %} +
+

+ Coming Soon... +

+

+ Coming Soon... +

+
+{% endblock content %} diff --git a/frontend/templates/layout.html b/frontend/templates/layout.html new file mode 100644 index 00000000..407d19ba --- /dev/null +++ b/frontend/templates/layout.html @@ -0,0 +1,31 @@ + + + + + {% block title %}light and sound design{% endblock title %} + + + {% block styles %} + {% endblock styles %} + + + {% block header %} +
+ +
+ {% endblock header %} +
{% block content %}{% endblock content %}
+ + + {% block scripts %} + {% endblock scripts %} + + diff --git a/frontend/templates/lists/confirmation.html b/frontend/templates/lists/confirmation.html new file mode 100644 index 00000000..b9155460 --- /dev/null +++ b/frontend/templates/lists/confirmation.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - success!{% endblock title %} + +{% block content %} +
+
+

You're now signed up for {{ list.description }}!

+

Please add {{ email }} to your safe senders list.

+
+
+{% endblock content %} diff --git a/frontend/templates/lists/edit.html b/frontend/templates/lists/edit.html new file mode 100644 index 00000000..1e0c94c9 --- /dev/null +++ b/frontend/templates/lists/edit.html @@ -0,0 +1,67 @@ +{% extends "layout.html" %} +{% block title %}Edit list - {{ list.name }}{% endblock %} +{% block styles %} +{% endblock %} + +{% block content %} +
+
+ {% if list.id != 0 %} + + {% endif %} +
+ + +
+
+ + +
+ {% if list.id != 0 %} +
+ +
    + {% for member in members %} +
  • + {% if let Some(first_name) = member.first_name %} + {% if let Some(last_name) = member.last_name %} + {{ member.email }} + ({{ first_name }} {{ last_name }}) + {% else %} + {{ member.email }} + {% endif %} + {% else %} + {{ member.email }} + {% endif %} + +
  • + {% endfor %} +
+
+ {% endif %} +
+ + +
+ +
+
+{% endblock %} diff --git a/frontend/templates/lists/signup.html b/frontend/templates/lists/signup.html new file mode 100644 index 00000000..19e88c2c --- /dev/null +++ b/frontend/templates/lists/signup.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - Sign Up{% endblock title %} + +{% block content %} +
+
+

Sign up for {{ list.description }}

+
+
+ + +
+ + +
+
+
+{% endblock content %} diff --git a/frontend/templates/lists/view.html b/frontend/templates/lists/view.html new file mode 100644 index 00000000..a12dd392 --- /dev/null +++ b/frontend/templates/lists/view.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} +{% block title %}light and sound - Lists{% endblock title %} + +{% block content %} +
+
+

Lists

+
+ +
+{% endblock %} diff --git a/frontend/templates/posts/edit.html b/frontend/templates/posts/edit.html new file mode 100644 index 00000000..c1d8a713 --- /dev/null +++ b/frontend/templates/posts/edit.html @@ -0,0 +1,227 @@ +{% extends "layout.html" %} +{% block title %}Edit post - {{ post.title }}{% endblock title %} + +{% block content %} +
+
+ +
+
+
+
+
+
+ {% if post.id != 0 %} + + {% endif %} +
+ + +
+
+ + +
+
+ + +
+
+
+
+{% endblock content %} + +{% block scripts %} + + +{% endblock scripts %} diff --git a/frontend/templates/posts/email.html b/frontend/templates/posts/email.html new file mode 100644 index 00000000..4d7b0e5b --- /dev/null +++ b/frontend/templates/posts/email.html @@ -0,0 +1,143 @@ +{# TODO email template file? #} + + + + + + + + {{ post.title }} + + + + +
+ +
+
+
+

{{ post.title }}

+ + {{ post.content | safe }} + footer +
+
+ + + diff --git a/frontend/templates/posts/list.html b/frontend/templates/posts/list.html new file mode 100644 index 00000000..af7e2d76 --- /dev/null +++ b/frontend/templates/posts/list.html @@ -0,0 +1,42 @@ +{% extends "layout.html" %} +{% block title %}light and sound{% endblock %} + +{% block content %} +
+
+

Posts

+ New Post +
+ {% for post in posts %} +
+
+

+ {{ post.title }} +

+ By {{ post.author }} • Updated + +
+ +
+ {% endfor %} +
+{% endblock content %} diff --git a/frontend/templates/posts/send.html b/frontend/templates/posts/send.html new file mode 100644 index 00000000..dce8ce46 --- /dev/null +++ b/frontend/templates/posts/send.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} +{% block title %}Send post - {{ post.title }}{% endblock %} + +{% block content %} +
+
+
Send post
+ +

{{ post.title }}

+
+ + +
+ +
+
+{% endblock content %} diff --git a/frontend/templates/posts/sent.html b/frontend/templates/posts/sent.html new file mode 100644 index 00000000..799de84b --- /dev/null +++ b/frontend/templates/posts/sent.html @@ -0,0 +1,39 @@ +{% extends "layout.html" %} +{% block title %}Sent post - {{ post_title }}{% endblock title %} +{% block styles %} + +{% endblock styles %} + +{% block content %} +
+

{{ post_title }}

+
    +
  • Sent {{ num_sent }} emails to list "{{ list_name }}"
  • + + {% if num_skipped > 0 %} +
  • + Skipped sending {{ num_skipped }} emails which were already delivered +
  • + {% endif %} + + {% if !errors.is_empty() %} +
  • + Failed to send {{ errors.len() }} emails +
      + {% for (email, message) in errors.iter() %} +
    • {{ email }}: {{ message }}
    • + {% endfor %} +
    • foo@foo.com: test
    • +
    +
  • + {% endif %} +
+
+{% endblock content %} diff --git a/frontend/templates/posts/view.html b/frontend/templates/posts/view.html new file mode 100644 index 00000000..8716e7c5 --- /dev/null +++ b/frontend/templates/posts/view.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} +{% block title %}{{ post.title }}{% endblock title %} + +{% block content %} +
+
+
+

{{ post.title }}

+ +
+
{{ post.content | safe }}
+
+
+{% endblock %} diff --git a/frontend/templates/tickets/create.html b/frontend/templates/tickets/create.html new file mode 100644 index 00000000..db5c0d2c --- /dev/null +++ b/frontend/templates/tickets/create.html @@ -0,0 +1,37 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - new ticket{% endblock title %} + +{% block content %} +
+

Let's Create a Ticket

+
+
+ + +
+ +
+ + +
+ + +
+
+ +{% endblock content %} diff --git a/frontend/templates/tickets/edit.html b/frontend/templates/tickets/edit.html new file mode 100644 index 00000000..6365db02 --- /dev/null +++ b/frontend/templates/tickets/edit.html @@ -0,0 +1,74 @@ +{% extends "layout.html" %} + +{% block title %}Edit {{ ticket.name }}{% endblock title %} + +{% block content %} +
+

Edit Ticket: {{ ticket.name }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+ + +{% endblock content %} diff --git a/frontend/templates/tickets/list.html b/frontend/templates/tickets/list.html new file mode 100644 index 00000000..fbd91d03 --- /dev/null +++ b/frontend/templates/tickets/list.html @@ -0,0 +1,101 @@ +{% extends "layout.html" %} + +{% block title %}light and sound - tickets{% endblock title %} + +{% block content %} + + + +{% endblock content %} diff --git a/frontend/templates/tickets/view.html b/frontend/templates/tickets/view.html new file mode 100644 index 00000000..473c4c7c --- /dev/null +++ b/frontend/templates/tickets/view.html @@ -0,0 +1,43 @@ +{% extends "layout.html" %} + +{% block title %}{{ ticket.name }}{% endblock title %} + +{% block content %} +
+

Update Ticket: {{ ticket.name }}

+
+
+ + +
+ +
+ + +
+ +
+ +
+ +
+
+ +{% endblock content %} diff --git a/migrations/0000_drop_migrations.sql b/migrations/0000_drop_migrations.sql new file mode 100644 index 00000000..3f95094b --- /dev/null +++ b/migrations/0000_drop_migrations.sql @@ -0,0 +1,2 @@ +-- Drop the old custom migrations table in favor of the SQLx managed table +DROP TABLE IF EXISTS migrations; \ No newline at end of file diff --git a/migrations/0001_create_emails.down.sql b/migrations/0001_create_emails.down.sql new file mode 100644 index 00000000..bb5088d5 --- /dev/null +++ b/migrations/0001_create_emails.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS emails; \ No newline at end of file diff --git a/migrations/0001_create_emails.up.sql b/migrations/0001_create_emails.up.sql new file mode 100644 index 00000000..195740f2 --- /dev/null +++ b/migrations/0001_create_emails.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY NOT NULL, + kind TEXT NOT NULL, + address TEXT NOT NULL, + post_id INTEGER, + list_id INTEGER, + error TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sent_at TIMESTAMP, + opened_at TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0002_create_events.down.sql b/migrations/0002_create_events.down.sql new file mode 100644 index 00000000..bfd996dc --- /dev/null +++ b/migrations/0002_create_events.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS events; \ No newline at end of file diff --git a/migrations/0002_create_events.up.sql b/migrations/0002_create_events.up.sql new file mode 100644 index 00000000..9ff71f95 --- /dev/null +++ b/migrations/0002_create_events.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + artist TEXT NOT NULL, + description TEXT NOT NULL, + start_date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0003_create_lists.down.sql b/migrations/0003_create_lists.down.sql new file mode 100644 index 00000000..d9b740f0 --- /dev/null +++ b/migrations/0003_create_lists.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS lists; \ No newline at end of file diff --git a/migrations/0003_create_lists.up.sql b/migrations/0003_create_lists.up.sql new file mode 100644 index 00000000..d5278b91 --- /dev/null +++ b/migrations/0003_create_lists.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS lists ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0004_create_list_members.down.sql b/migrations/0004_create_list_members.down.sql new file mode 100644 index 00000000..478b7b2c --- /dev/null +++ b/migrations/0004_create_list_members.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS list_members; \ No newline at end of file diff --git a/migrations/0004_create_list_members.up.sql b/migrations/0004_create_list_members.up.sql new file mode 100644 index 00000000..fe47caa3 --- /dev/null +++ b/migrations/0004_create_list_members.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS list_members ( + list_id INTEGER NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (list_id, email) +); \ No newline at end of file diff --git a/migrations/0005_create_posts.down.sql b/migrations/0005_create_posts.down.sql new file mode 100644 index 00000000..52ac968d --- /dev/null +++ b/migrations/0005_create_posts.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS posts; \ No newline at end of file diff --git a/migrations/0005_create_posts.up.sql b/migrations/0005_create_posts.up.sql new file mode 100644 index 00000000..fa18fb7e --- /dev/null +++ b/migrations/0005_create_posts.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + author TEXT NOT NULL, + content TEXT NOT NULL, + content_rendered TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0006_create_login_tokens.down.sql b/migrations/0006_create_login_tokens.down.sql new file mode 100644 index 00000000..d2f607c5 --- /dev/null +++ b/migrations/0006_create_login_tokens.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/migrations/0006_create_login_tokens.up.sql b/migrations/0006_create_login_tokens.up.sql new file mode 100644 index 00000000..ba078b25 --- /dev/null +++ b/migrations/0006_create_login_tokens.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS login_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + email TEXT NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.down.sql b/migrations/0007_create_session_tokens.down.sql new file mode 100644 index 00000000..918799d3 --- /dev/null +++ b/migrations/0007_create_session_tokens.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS session_tokens; \ No newline at end of file diff --git a/migrations/0007_create_session_tokens.up.sql b/migrations/0007_create_session_tokens.up.sql new file mode 100644 index 00000000..796f0b4f --- /dev/null +++ b/migrations/0007_create_session_tokens.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS session_tokens ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +); \ No newline at end of file diff --git a/migrations/0008_create_users.down.sql b/migrations/0008_create_users.down.sql new file mode 100644 index 00000000..365a2107 --- /dev/null +++ b/migrations/0008_create_users.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/migrations/0008_create_users.up.sql b/migrations/0008_create_users.up.sql new file mode 100644 index 00000000..f6d4b9ab --- /dev/null +++ b/migrations/0008_create_users.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY NOT NULL, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/migrations/0009_create_user_roles.down.sql b/migrations/0009_create_user_roles.down.sql new file mode 100644 index 00000000..a78eae2a --- /dev/null +++ b/migrations/0009_create_user_roles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS user_roles; \ No newline at end of file diff --git a/migrations/0009_create_user_roles.up.sql b/migrations/0009_create_user_roles.up.sql new file mode 100644 index 00000000..ad6a07cc --- /dev/null +++ b/migrations/0009_create_user_roles.up.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role) +); \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.down.sql b/migrations/0010_remove_column_content_rendered_posts.down.sql new file mode 100644 index 00000000..4b12b055 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts ADD content_rendered TEXT NOT NULL; +UPDATE posts SET content_rendered = content; \ No newline at end of file diff --git a/migrations/0010_remove_column_content_rendered_posts.up.sql b/migrations/0010_remove_column_content_rendered_posts.up.sql new file mode 100644 index 00000000..5e6804b4 --- /dev/null +++ b/migrations/0010_remove_column_content_rendered_posts.up.sql @@ -0,0 +1,2 @@ +UPDATE posts SET content = content_rendered; +ALTER TABLE posts DROP COLUMN content_rendered; \ No newline at end of file diff --git a/migrations/0011_remove_users_name_nullability.down.sql b/migrations/0011_remove_users_name_nullability.down.sql new file mode 100644 index 00000000..553d34ae --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.down.sql @@ -0,0 +1,12 @@ +PRAGMA writable_schema = ON; + +DELETE FROM users WHERE first_name IS NULL OR last_name IS NULL; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT', 'first_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT', 'last_name TEXT NOT NULL') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0011_remove_users_name_nullability.up.sql b/migrations/0011_remove_users_name_nullability.up.sql new file mode 100644 index 00000000..5529226d --- /dev/null +++ b/migrations/0011_remove_users_name_nullability.up.sql @@ -0,0 +1,10 @@ +PRAGMA writable_schema = ON; + +UPDATE sqlite_master + SET sql = replace(sql, 'first_name TEXT NOT NULL', 'first_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; +UPDATE sqlite_master + SET sql = replace(sql, 'last_name TEXT NOT NULL', 'last_name TEXT') + WHERE tbl_name = 'users' AND type = 'table'; + +PRAGMA writable_schema = OFF; diff --git a/migrations/0012_add_email_xrefs.down.sql b/migrations/0012_add_email_xrefs.down.sql new file mode 100644 index 00000000..47d5e8bf --- /dev/null +++ b/migrations/0012_add_email_xrefs.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE emails DROP COLUMN user_id; +ALTER TABLE emails DROP COLUMN event_id; diff --git a/migrations/0012_add_email_xrefs.up.sql b/migrations/0012_add_email_xrefs.up.sql new file mode 100644 index 00000000..b11685c3 --- /dev/null +++ b/migrations/0012_add_email_xrefs.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE emails ADD COLUMN user_id INTEGER; +ALTER TABLE emails ADD COLUMN event_id INTEGER; diff --git a/migrations/0013_add_events_fields.down.sql b/migrations/0013_add_events_fields.down.sql new file mode 100644 index 00000000..4573748f --- /dev/null +++ b/migrations/0013_add_events_fields.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE events ADD COLUMN artist TEXT NOT NULL; +ALTER TABLE events ADD COLUMN start_date TIMESTAMP NOT NULL; + +ALTER TABLE events DROP COLUMN slug; +ALTER TABLE events DROP COLUMN flyer; +ALTER TABLE events DROP COLUMN start; +ALTER TABLE events DROP COLUMN end; +ALTER TABLE events DROP COLUMN capacity; +ALTER TABLE events DROP COLUMN guest_list_id; diff --git a/migrations/0013_add_events_fields.up.sql b/migrations/0013_add_events_fields.up.sql new file mode 100644 index 00000000..43fa89fb --- /dev/null +++ b/migrations/0013_add_events_fields.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE events DROP COLUMN artist; +ALTER TABLE events DROP COLUMN start_date; + +ALTER TABLE events ADD COLUMN slug TEXT NOT NULL; +ALTER TABLE events ADD COLUMN flyer TEXT; +ALTER TABLE events ADD COLUMN start TIMESTAMP NOT NULL; +ALTER TABLE events ADD COLUMN end TIMESTAMP; +ALTER TABLE events ADD COLUMN guest_list_id INTEGER; diff --git a/migrations/0014_create_notifications.down.sql b/migrations/0014_create_notifications.down.sql new file mode 100644 index 00000000..9389dda5 --- /dev/null +++ b/migrations/0014_create_notifications.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS notifications; +DROP TABLE IF EXISTS event_notifications; diff --git a/migrations/0014_create_notifications.up.sql b/migrations/0014_create_notifications.up.sql new file mode 100644 index 00000000..5c64f181 --- /dev/null +++ b/migrations/0014_create_notifications.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY NOT NULL, + event_id INTEGER, + name TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_notifications ( + event_id INTEGER NOT NULL, + notification_id INTEGER NOT NULL, + mins_before_start INTEGER NOT NULL, + PRIMARY KEY (event_id, notification_id) +); diff --git a/migrations/0015_create_tickets.down.sql b/migrations/0015_create_tickets.down.sql new file mode 100644 index 00000000..9639e59b --- /dev/null +++ b/migrations/0015_create_tickets.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS event_tickets; +DROP TABLE IF EXISTS tickets; diff --git a/migrations/0015_create_tickets.up.sql b/migrations/0015_create_tickets.up.sql new file mode 100644 index 00000000..c1eb2f74 --- /dev/null +++ b/migrations/0015_create_tickets.up.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS tickets ( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_tickets ( + event_id INTEGER NOT NULL, + ticket_id INTEGER NOT NULL, + price INTEGER NOT NULL, + quantity INTEGER NOT NULL, + sort INTEGER NOT NULL, + PRIMARY KEY (event_id, ticket_id) +); diff --git a/migrations/0016_create_rsvps.down.sql b/migrations/0016_create_rsvps.down.sql new file mode 100644 index 00000000..e125f39c --- /dev/null +++ b/migrations/0016_create_rsvps.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS rsvps; diff --git a/migrations/0016_create_rsvps.up.sql b/migrations/0016_create_rsvps.up.sql new file mode 100644 index 00000000..4275f7ec --- /dev/null +++ b/migrations/0016_create_rsvps.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS rsvps ( + id INTEGER PRIMARY KEY NOT NULL, + user_id INTEGER NOT NULL, + event_id INTEGER NOT NULL, + ticket_id INTEGER NOT NULL, + + transaction_id INTEGER, + promo_id INTEGER, + refund_id INTEGER, + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + arrived_at TIMESTAMP +); diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..7b931342 --- /dev/null +++ b/mise.toml @@ -0,0 +1,3 @@ +[tools] +node = "latest" +rust = "latest" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..afb49358 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3269 @@ +{ + "name": "lsd", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/cli": "^4.1.2", + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.1.3.tgz", + "integrity": "sha512-irQW1LhBCi8O7OPrDVTyo6IZFqUDukGkcqOIxoU9d7zSOxU5LZQ1EB1KA981xmZpPIIfaowgdia8FSxaQrBonQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.1.3", + "@tailwindcss/oxide": "4.1.3", + "enhanced-resolve": "^5.18.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.1.3" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.3.tgz", + "integrity": "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.3.tgz", + "integrity": "sha512-t16lpHCU7LBxDe/8dCj9ntyNpXaSTAgxWm1u2XQP5NiIu4KGSyrDJJRlK9hJ4U9yJxx0UKCVI67MJWFNll5mOQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-arm64": "4.1.3", + "@tailwindcss/oxide-darwin-x64": "4.1.3", + "@tailwindcss/oxide-freebsd-x64": "4.1.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.3", + "@tailwindcss/oxide-linux-x64-musl": "4.1.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.3.tgz", + "integrity": "sha512-cxklKjtNLwFl3mDYw4XpEfBY+G8ssSg9ADL4Wm6//5woi3XGqlxFsnV5Zb6v07dxw1NvEX2uoqsxO/zWQsgR+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.3.tgz", + "integrity": "sha512-mqkf2tLR5VCrjBvuRDwzKNShRu99gCAVMkVsaEOFvv6cCjlEKXRecPu9DEnxp6STk5z+Vlbh1M5zY3nQCXMXhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.3.tgz", + "integrity": "sha512-7sGraGaWzXvCLyxrc7d+CCpUN3fYnkkcso3rCzwUmo/LteAl2ZGCDlGvDD8Y/1D3ngxT8KgDj1DSwOnNewKhmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.3.tgz", + "integrity": "sha512-E2+PbcbzIReaAYZe997wb9rId246yDkCwAakllAWSGqe6VTg9hHle67hfH6ExjpV2LSK/siRzBUs5wVff3RW9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.3.tgz", + "integrity": "sha512-GvfbJ8wjSSjbLFFE3UYz4Eh8i4L6GiEYqCtA8j2Zd2oXriPuom/Ah/64pg/szWycQpzRnbDiJozoxFU2oJZyfg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.3.tgz", + "integrity": "sha512-35UkuCWQTeG9BHcBQXndDOrpsnt3Pj9NVIB4CgNiKmpG8GnCNXeMczkUpOoqcOhO6Cc/mM2W7kaQ/MTEENDDXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.3.tgz", + "integrity": "sha512-dm18aQiML5QCj9DQo7wMbt1Z2tl3Giht54uVR87a84X8qRtuXxUqnKQkRDK5B4bCOmcZ580lF9YcoMkbDYTXHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.3.tgz", + "integrity": "sha512-LMdTmGe/NPtGOaOfV2HuO7w07jI3cflPrVq5CXl+2O93DCewADK0uW1ORNAcfu2YxDUS035eY2W38TxrsqngxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.3.tgz", + "integrity": "sha512-aalNWwIi54bbFEizwl1/XpmdDrOaCjRFQRgtbv9slWjmNPuJJTIKPHf5/XXDARc9CneW9FkSTqTbyvNecYAEGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.3.tgz", + "integrity": "sha512-PEj7XR4OGTGoboTIAdXicKuWl4EQIjKHKuR+bFy9oYN7CFZo0eu74+70O4XuERX4yjqVZGAkCdglBODlgqcCXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.3.tgz", + "integrity": "sha512-T8gfxECWDBENotpw3HR9SmNiHC9AOJdxs+woasRZ8Q/J4VHN0OMs7F+4yVNZ9EVN26Wv6mZbK0jv7eHYuLJLwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/livereload": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/livereload/-/livereload-0.9.3.tgz", + "integrity": "sha512-q7Z71n3i4X0R9xthAryBdNGVGAO2R5X+/xXpmKeuPMrteg+W2U8VusTKV3YiJbXZwKsOlFlHe+go6uSNjfxrZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.0", + "livereload-js": "^3.3.1", + "opts": ">= 1.2.0", + "ws": "^7.4.3" + }, + "bin": { + "livereload": "bin/livereload.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/livereload-js": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/livereload-js/-/livereload-js-3.4.1.tgz", + "integrity": "sha512-5MP0uUeVCec89ZbNOT/i97Mc+q3SxXmiUGhRFOTmhrGPn//uWVQdCvcLJDy64MSBR5MidFdOR7B9viumoavy6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memorystream": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", + "integrity": "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==", + "dev": true, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-all": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", + "integrity": "sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "chalk": "^2.4.1", + "cross-spawn": "^6.0.5", + "memorystream": "^0.3.1", + "minimatch": "^3.0.4", + "pidtree": "^0.3.0", + "read-pkg": "^3.0.0", + "shell-quote": "^1.6.1", + "string.prototype.padend": "^3.0.0" + }, + "bin": { + "npm-run-all": "bin/npm-run-all/index.js", + "run-p": "bin/run-p/index.js", + "run-s": "bin/run-s/index.js" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/opts/-/opts-2.0.2.tgz", + "integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.3.1.tgz", + "integrity": "sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-jinja-template": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-jinja-template/-/prettier-plugin-jinja-template-2.0.0.tgz", + "integrity": "sha512-REZDAcZuOUvMDaPS47/GNRLKvbxh9DO9euXhWA7gJGqTLGzHPK2Z841F8I4bxsR7e2lqnHezkQ8GcWaKekKBVQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.11.tgz", + "integrity": "sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/string.prototype.padend": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", + "integrity": "sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.3.tgz", + "integrity": "sha512-2Q+rw9vy1WFXu5cIxlvsabCwhU2qUwodGq03ODhLJ0jW4ek5BUtoCsnLB0qG+m8AHgEsSJcJGDSDe06FXlP74g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..8ffc4f64 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "scripts": { + "watch": "npm-run-all --parallel watch:styles reload:static", + "reload:static": "npx livereload ./frontend/static", + "watch:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --watch", + "build:styles": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css", + "build:styles.min": "npx tailwindcss -i ./frontend/styles/main.css -o ./frontend/static/dist/main.css --minify", + "format": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --write", + "format:check": "npx prettier ./frontend/templates ./frontend/styles --config frontend/prettier.config.cjs --check" + }, + "devDependencies": { + "livereload": "^0.9.3", + "npm-run-all": "^4.1.5", + "prettier": "^3.5.3", + "prettier-plugin-jinja-template": "^2.0.0", + "prettier-plugin-tailwindcss": "^0.6.11", + "@tailwindcss/cli": "^4.1.2" + } +} \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml index a71193aa..2a2e967b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -3,3 +3,5 @@ chain_width = 80 fn_call_width = 80 struct_lit_width = 75 single_line_if_else_max_width = 80 +group_imports = "StdExternalCrate" +imports_granularity = "Module" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 2fa91abb..a77ce5c6 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -8,36 +8,30 @@ fi ssh $1 <<'EOS' # update sudo yum update -y +sudo timedatectl set-timezone America/New_York # create a user -if ! id wlsd &>/dev/null; then - sudo adduser wlsd +if ! id lsd &>/dev/null; then + sudo adduser lsd fi -# add ssh keys -cat > .ssh/authorized_keys </dev/null +sudo tee /etc/systemd/system/lsd.service </dev/null [Unit] -Description=WLSD +Description=LSD After=network.target [Service] Type=simple -User=wlsd -WorkingDirectory=/home/wlsd -ExecStart=/home/wlsd/wlsd /home/wlsd/config/prod.toml +User=lsd +WorkingDirectory=/home/lsd +ExecStart=/home/lsd/lsd /home/lsd/config/prod.toml Restart=always [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload -sudo systemctl enable wlsd -sudo systemctl restart wlsd +sudo systemctl enable lsd +sudo systemctl restart lsd EOS diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 5d40d145..ec76ed17 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -5,12 +5,14 @@ if [ $# -ne 1 ]; then exit 1 fi -cargo build --profile prod --target aarch64-unknown-linux-gnu +cargo build --release --target aarch64-unknown-linux-gnu ls -l target/aarch64-unknown-linux-gnu/ ls -l target/aarch64-unknown-linux-gnu/* -rsync --rsync-path="sudo rsync" -Pavzr --delete assets templates config target/aarch64-unknown-linux-gnu/prod/wlsd $1:/home/wlsd/ +envsubst < config/prod.toml > config/prod.toml.subst +mv config/prod.toml.subst config/prod.toml +rsync --rsync-path="sudo rsync" -Pavzr --delete frontend config target/aarch64-unknown-linux-gnu/release/lsd $1:/home/lsd/ ssh $1 <<'EOS' -sudo setcap 'cap_net_bind_service=+ep' /home/wlsd/wlsd -sudo systemctl restart wlsd +sudo setcap 'cap_net_bind_service=+ep' /home/lsd/lsd +sudo systemctl restart lsd EOS diff --git a/src/app/auth.rs b/src/app/auth.rs new file mode 100644 index 00000000..874f60fe --- /dev/null +++ b/src/app/auth.rs @@ -0,0 +1,178 @@ +//! A simple passwordless authentication flow using one-time links sent via email. +//! +//! TODO: Switch to one-time codes (123-456) instead of links: +//! * More robust against clients and intermediaries that auto-open URLs +//! * Easier to transfer across devices than a magic link +//! +//! We choose this scheme instead of one with usernames/passwords to reduce +//! friction and simplify onboarding. +//! +//! # High-level flow +//! +//! 1. **Email input**: User enters their email and submits a login form. +//! 2. **Token generated**: Server creates a short-lived link with a login token and emails it to the user. +//! - If the user is already registered, the link points to `/login?token=...`. +//! - If the user is not registered, the link points to `/register?token=...`. +//! 3. **Link clicked**: User clicks the link, passing the token back to the server. +//! - `/login`: The user gets a new session cookie and is redirected home. +//! - `/register`: The user is prompted to enter their first/last name. +//! Upon submission, the user gets a new session cookie and is redirected home. + +use axum_extra::extract::CookieJar; +use lettre::message::header::ContentType; +use lettre::message::Mailbox; + +use crate::db::token::{LoginToken, SessionToken}; +use crate::db::user::UpdateUser; +use crate::prelude::*; + +/// Add all `auth` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/login", post(login_form).get(login_link)) + .route("/register", post(register_form).get(register_link)) + }) +} + +/// Add all `auth` middleware to the router. +pub fn add_middleware(router: AxumRouter, state: SharedAppState) -> AxumRouter { + router.layer(axum::middleware::from_fn_with_state(state, auth_middleware)) +} + +/// Middleware layer to lookup add a `User` to the request if a session token is present. +pub async fn auth_middleware( + State(state): State, + mut cookies: CookieJar, + mut request: Request, + next: Next, +) -> AppResult<(CookieJar, Response)> { + if let Some(token) = cookies.get("session") { + match User::lookup_by_session_token(&state.db, token.value()).await? { + Some(user) => { + request.extensions_mut().insert(user); + } + None => cookies = cookies.remove("session"), + } + } + let response = next.run(request).await; + Ok((cookies, response)) +} + +/// Process a login form and send either a login or registration link via email. +async fn login_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let email = form.email.email.to_string(); + let login_token = LoginToken::create(&state.db, &email).await?; + + let email_id = Email::create_login(&state.db, &email).await?; + + let domain = &state.config.app.domain; + let base_url = &state.config.app.url; + let msg = state.mailer.builder().header(ContentType::TEXT_PLAIN).to(form.email); + + let msg = match User::lookup_by_email(&state.db, &email).await? { + Some(_) => { + let url = format!("{base_url}/login?token={login_token}"); + msg.subject(format!("Login to {domain}")) + .body(format!("Click here to login: {url}"))? + } + None => { + let url = format!("{base_url}/register?token={login_token}"); + msg.subject(format!("Register at {domain}")) + .body(format!("Click here to complete your registration: {url}"))? + } + }; + + match state.mailer.send(msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + Ok("Check your email!") + } + Err(e) => { + Email::mark_error(&state.db, email_id, &e.to_string()).await?; + Err(e) + } + } +} +#[derive(serde::Deserialize)] +struct LoginForm { + email: Mailbox, +} + +/// Login from a link containing a token, creating a new sesssion. +async fn login_link( + State(state): State, + Query(query): Query, +) -> AppResult { + #[derive(Template, WebTemplate)] + #[template(path = "auth/login.html")] + pub struct Html; + + match query.token { + Some(token) => { + let user = User::lookup_by_login_token(&state.db, &token) + .await? + .ok_or(AppError::NotAuthorized)?; + + let token = SessionToken::create(&state.db, user.id).await?; + let headers = ( + // TODO: expiration date + [(header::SET_COOKIE, format!("session={token}; Secure; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) + } + None => Ok(Html.into_response()), + } +} +#[derive(serde::Deserialize)] +struct LoginQuery { + token: Option, +} + +/// Display the registration page. +async fn register_link(Query(query): Query) -> impl IntoResponse { + #[derive(Template, WebTemplate)] + #[template(path = "auth/register.html")] + pub struct Html { + pub token: String, + } + Html { token: query.token } +} +#[derive(serde::Deserialize)] +struct RegisterQuery { + token: String, +} + +/// Process the registration form and create a new user. +async fn register_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let Some(email) = LoginToken::lookup_email(&state.db, &form.token).await? else { + return Err(AppError::NotAuthorized); + }; + + let user_id = User::create( + &state.db, + &UpdateUser { first_name: form.first_name, last_name: form.last_name, email }, + ) + .await?; + + // TODO: Expiration date on the cookie + let session_token = SessionToken::create(&state.db, user_id).await?; + let headers = ( + [(header::SET_COOKIE, format!("session={session_token}; Secure"))], + Redirect::to(&state.config.app.url), + ); + Ok(headers.into_response()) +} +#[derive(serde::Deserialize)] +struct RegisterForm { + token: String, + first_name: String, + last_name: String, +} diff --git a/src/app/emails.rs b/src/app/emails.rs new file mode 100644 index 00000000..f26f087d --- /dev/null +++ b/src/app/emails.rs @@ -0,0 +1,85 @@ +use crate::db::list::List; +use crate::prelude::*; + +/// Add all `email` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/emails/{id}/footer.gif", get(email_opened)) + .route("/emails/{id}/unsubscribe", get(email_unsubscribe_view).post(email_unsubscribe_form)) + }) +} + +async fn email_opened(Path(email_id): Path, State(state): State) -> AppResult { + Email::mark_opened(&state.db, email_id).await?; + let pixel = Response::builder() + .status(StatusCode::OK) + .header("Content-Type", "image/gif") + .body(PIXEL.into()) + .unwrap(); + Ok(pixel) +} + +async fn email_unsubscribe_view( + Path(email_id): Path, + State(state): State, +) -> AppResult { + #[derive(Template, WebTemplate)] + #[template(path = "emails/unsubscribe.html")] + pub struct Html { + pub email_id: i64, + pub email_address: String, + } + + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? { + return Ok(Html { email_id, email_address: email.address }.into_response()); + } + + Ok("You have been unsubscribed.".into_response()) +} + +async fn email_unsubscribe_form( + Path(email_id): Path, + State(state): State, +) -> AppResult { + // TODO: Better error handling rather than silently eating + if let Some(email) = Email::lookup(&state.db, email_id).await? { + if let Some(list_id) = email.list_id { + List::remove_member(&state.db, list_id, &email.address).await?; + } + } + Ok("You have been unsubscribed.".into_response()) +} + +/// A 1x1 transparent GIF. +#[rustfmt::skip] +const PIXEL: &[u8] = &[ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // Header: "GIF89a" + 0x01, 0x00, // Logical Screen Width: 1 + 0x01, 0x00, // Logical Screen Height: 1 + 0x80, // GCT flag = 1, Color Resolution = 0, Sort = 0, GCT Size = 2^(0+1)=2 colors + 0x00, // Background Color Index = 0 + 0x00, // Pixel Aspect Ratio = 0 (no aspect ratio given) + // Global Color Table (2 entries, each 3 bytes: RGB) + 0x00, 0x00, 0x00, // Index #0: black (will be set as transparent) + 0x00, 0x00, 0x00, // Index #1: black + // Graphic Control Extension + 0x21, 0xF9, 0x04, // Extension Introducer (0x21), GCE Label (0xF9), Block Size (4) + 0x01, // Packed Fields: bit 0 = 1 => Transparent Color Flag + 0x00, 0x00, // Delay Time = 0 + 0x00, // Transparent Color Index = 0 + 0x00, // Block Terminator + // Image Descriptor + 0x2C, // Image separator: ',' + 0x00, 0x00, 0x00, 0x00, // Image Position: (0,0) + 0x01, 0x00, // Image Width: 1 + 0x01, 0x00, // Image Height: 1 + 0x00, // No Local Color Table, no interlace, etc. + // Image Data + 0x02, // LZW Minimum Code Size + 0x02, // Block Size (number of bytes of LZW data in this sub-block) + 0x4C, 0x01, // LZW-compressed data + 0x00, // Block Terminator (end of image data) + 0x3B, // Trailer: ';' +]; diff --git a/src/app/events.rs b/src/app/events.rs new file mode 100644 index 00000000..e7a9285d --- /dev/null +++ b/src/app/events.rs @@ -0,0 +1,267 @@ +use crate::db::event::{Event, UpdateEvent}; +use crate::db::event_ticket::EventTicket; +use crate::db::ticket::Ticket; +use crate::prelude::*; + +/// Add all `events` routes to the router. +pub fn add_routes(router: AppRouter) -> AppRouter { + router.restricted_routes(User::ADMIN, |r| { + r.route("/events", get(list_events_page)) + .route("/events/new", get(create_event_page).post(create_event_form)) + .route("/events/{id}", get(view_event_page)) + .route("/events/{id}/edit", get(edit_event_page).post(update_event_form)) + .route("/events/{id}/delete", post(delete_event)) + }) +} + +/// Display a list of all events. +async fn list_events_page( + State(state): State, + Query(query): Query, +) -> AppResult { + let now = Utc::now().naive_utc(); + let past = query.past.unwrap_or(false); + + let events = Event::list(&state.db) + .await? + .into_iter() + .filter(|e| match past { + true => e.start < now, + // TODO: Don't filter out in-progress events until they're over. + // Need to add an `end_date` field. + false => e.start >= now, + }) + .collect::>(); + + #[derive(Template, WebTemplate)] + #[template(path = "events/list.html")] + pub struct Html { + pub events: Vec, + } + Ok(Html { events }) +} +#[derive(serde::Deserialize)] +struct ListEventsQuery { + past: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct CreateEventWithTickets { + #[serde(flatten)] + pub event: UpdateEvent, + pub tickets: Vec, + pub cover_image: Option, + pub cover_image_filename: Option, +} + +#[derive(serde::Deserialize)] +struct UpdateEventWithTickets { + #[serde(flatten)] + pub event: UpdateEvent, + pub tickets: Vec, + pub cover_image: Option, + pub cover_image_filename: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct EventTicketForm { + pub ticket_id: i64, + pub price: i64, + pub quantity: i64, + pub sort: i64, +} + +/// Display the form to create a new event with ticket type selection. +async fn create_event_page(State(state): State) -> AppResult { + let tickets = Ticket::list(&state.db).await?; + #[derive(Template, WebTemplate)] + #[template(path = "events/create.html")] + pub struct Html { + pub tickets: Vec, + } + Ok(Html { tickets }) +} + +/// Process the form and create a new event with tickets. +async fn create_event_form( + State(state): State, + Json(form): Json, +) -> AppResult { + println!("Raw form data: {:?}", form); + + //Handle cover image processing + let processed_flyer = if let Some(base64_image) = &form.cover_image { + match process_image(base64_image, &form.cover_image_filename).await { + Ok(path) => { + println!("Successfully processed image, saved to: {}", path); + Some(path) + } + Err(e) => { + println!("Error processing image: {:?}", e); + return Err(e); + } + } + } else { + println!("No cover image provided"); + None + }; + + //Create event with processed cover image path + let mut event = form.event.clone(); + event.flyer = processed_flyer.clone(); + + let event_id = Event::create(&state.db, &event).await?; + + sqlx::query!("DELETE FROM event_tickets WHERE event_id = ?", event_id) + .execute(&state.db) + .await?; + + for ticket_form in form.tickets { + if ticket_form.ticket_id > 0 + && ticket_form.price > 0 + && ticket_form.quantity > 0 + && ticket_form.sort >= 0 + { + EventTicket::create( + &state.db, + event_id, + ticket_form.ticket_id, + ticket_form.price, + ticket_form.quantity, + ticket_form.sort, + ) + .await?; + } + } + Ok(Redirect::to("/events")) +} + +/// Display the event details (read-only). +async fn view_event_page( + State(state): State, + Path(id): Path, +) -> AppResult { + let event = Event::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + let event_tickets = EventTicket::list_for_event(&state.db, id).await?; + let all_tickets = Ticket::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/view.html")] + pub struct Html { + pub event: Event, + pub event_tickets: Vec, + pub all_tickets: Vec, + } + Ok(Html { event, event_tickets, all_tickets }) +} + +/// Display the form to edit an existing event. +async fn edit_event_page( + State(state): State, + Path(id): Path, +) -> AppResult { + let event = Event::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + let event_tickets = EventTicket::list_for_event(&state.db, id).await?; + let all_tickets = Ticket::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "events/edit.html")] + pub struct Html { + pub event: Event, + pub event_tickets: Vec, + pub all_tickets: Vec, + } + Ok(Html { event, event_tickets, all_tickets }) +} + +/// Process the form and update an event. +async fn update_event_form( + State(state): State, + Path(id): Path, + Json(form): Json, +) -> AppResult { + // Get existing event to preserve flyer if no new image + let existing_event = Event::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + + let processed_flyer = if let Some(base64_image) = &form.cover_image { + println!("Processing new cover image"); + + // Delete old image if it exists + if let Some(old_flyer) = &existing_event.flyer { + if let Err(e) = delete_image(old_flyer).await { + println!("Warning: Failed to delete old image {}: {:?}", old_flyer, e); + } + } + + match process_image(base64_image, &form.cover_image_filename).await { + Ok(path) => { + println!("Successfully processed new image: {}", path); + Some(path) + } + Err(e) => { + println!("Error processing new image: {:?}", e); + return Err(e); + } + } + } else { + println!("No new image provided, keeping existing: {:?}", existing_event.flyer); + existing_event.flyer + }; + + let mut event = form.event; + event.flyer = processed_flyer.clone(); + + println!("Updating event with flyer: {:?}", event.flyer); + + Event::update(&state.db, id, &event).await?; + + // Delete existing event tickets first to prevent duplicates + sqlx::query!("DELETE FROM event_tickets WHERE event_id = ?", id) + .execute(&state.db) + .await?; + + for ticket_form in form.tickets { + if ticket_form.ticket_id > 0 && ticket_form.price > 0 && ticket_form.quantity > 0 { + EventTicket::create( + &state.db, + id, + ticket_form.ticket_id, + ticket_form.price, + ticket_form.quantity, + ticket_form.sort, + ) + .await?; + } + } + // TODO: Redirect to event page. + Ok(Redirect::to(&format!("/events/{}", id))) +} + +/// Delete an event. +async fn delete_event( + State(state): State, + Path(id): Path, +) -> AppResult { + // Check if event exists first + let existing_event = Event::lookup_by_id(&state.db, id).await?; + if existing_event.is_none() { + println!("DELETE EVENT: Event {} not found", id); + return Err(AppError::NotFound); + } + + let event = existing_event.unwrap(); + + // Delete associated image file if it exists + if let Some(flyer_path) = &event.flyer { + if let Err(e) = delete_image(flyer_path).await { + println!("Warning: Failed to delete image file {}: {:?}", flyer_path, e); + } else { + println!("Successfully deleted image file: {}", flyer_path); + } + } + + println!("DELETE EVENT: Event {} found, proceeding with deletion", id); + Event::delete(&state.db, id).await?; + println!("DELETE EVENT: Event {} deleted successfully, redirecting to /events", id); + Ok(Redirect::to("/events")) +} diff --git a/src/app/home.rs b/src/app/home.rs new file mode 100644 index 00000000..659b5af6 --- /dev/null +++ b/src/app/home.rs @@ -0,0 +1,14 @@ +use crate::prelude::*; + +/// Add all `home` routes to the router. +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| r.route("/", get(home_page))) +} + +/// Display the front page. +async fn home_page() -> impl IntoResponse { + #[derive(Template, WebTemplate)] + #[template(path = "index.html")] + pub struct Html; + Html +} diff --git a/src/app/lists.rs b/src/app/lists.rs new file mode 100644 index 00000000..e43fed07 --- /dev/null +++ b/src/app/lists.rs @@ -0,0 +1,159 @@ +use lettre::message::Mailbox; + +use crate::db::list::{List, ListMember, UpdateList}; +use crate::prelude::*; + +/// Add all `lists` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.public_routes(|r| { + r.route("/newsletter", get(newsletter_signup_page)) + .route("/lists/{id}/signup", get(signup_page).post(signup_form)) + }) + .restricted_routes(User::ADMIN, |r| { + r.route("/lists", get(list_lists_page)) + .route("/lists/new", get(create_list_page)) + .route("/lists/{id}", get(edit_list_page).post(edit_list_form)) + .route("/lists/{id}/{email}", delete(remove_list_member)) + }) +} + +#[derive(Template, WebTemplate)] +#[template(path = "lists/edit.html")] +pub struct ListEditHtml { + pub list: List, + pub members: Vec, +} + +/// Display a list of all lists +async fn list_lists_page(State(state): State) -> AppResult { + let lists = List::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "lists/view.html")] + pub struct Html { + pub lists: Vec, + } + Ok(Html { lists }) +} + +/// Display the form to view and edit a list. +async fn edit_list_page( + State(state): State, + Path(id): Path, +) -> AppResult { + let list = List::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + + let members = List::list_members(&state.db, id).await?; + + Ok(ListEditHtml { list, members }) +} + +/// Display the form to create a new list. +async fn create_list_page() -> AppResult { + let list = List { + id: 0, + name: "".into(), + description: "".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }; + + Ok(ListEditHtml { list, members: vec![] }) +} + +/// Process the form and create or edit a list. +async fn edit_list_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let id = match form.id { + Some(id) => { + List::update(&state.db, id, &form).await?; + id + } + None => List::create(&state.db, &form).await?, + }; + + let emails = form.emails.split_whitespace().collect::>(); + for email in &emails { + if let Err(e) = email.parse::() { + tracing::debug!("Invalid email: {e}"); + return Ok(StatusCode::BAD_REQUEST.into_response()); + } + } + if !emails.is_empty() { + List::add_members(&state.db, id, &emails).await?; + } + + Ok(Redirect::to(&format!("{}/lists/{}", state.config.app.url, id)).into_response()) +} + +async fn remove_list_member( + State(state): State, + Path((id, email)): Path<(i64, String)>, +) -> AppResult<()> { + List::remove_member(&state.db, id, &email).await?; + Ok(()) +} + +/// Display the newsletter signup page. +// XXX: Hard coded to list with id=1. +pub async fn newsletter_signup_page(State(state): State) -> AppResult { + signup_page(State(state), Path(1)).await +} + +/// Display the list signup page. +async fn signup_page( + State(state): State, + Path(list_id): Path, +) -> AppResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if list_id != 1 { + return Err(AppError::NotAuthorized); + } + + let Some(list) = List::lookup_by_id(&state.db, list_id).await? else { + return Err(AppError::NotFound); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "lists/signup.html")] + pub struct Html { + pub list: List, + } + Ok(Html { list }) +} + +/// Process the list signup form. +// +// XXX: We really should rate limit this. +async fn signup_form( + State(state): State, + Form(form): Form, +) -> AppResult { + // XXX: Hard code only allow id 1 to be signed up to. + // A flag should be added to List whether it's public or not, and what the signup page looks like. + if form.list_id != 1 { + return Err(AppError::NotAuthorized); + } + + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + return Err(AppError::NotFound); + }; + List::add_members(&state.db, list.id, &[form.email.email.as_ref()]).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "lists/confirmation.html")] + pub struct Html { + pub list: List, + pub email: String, + } + Ok(Html { list, email: state.config.email.from.email.to_string() }) +} +#[derive(serde::Deserialize)] +struct NewsletterForm { + list_id: i64, + email: Mailbox, +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 4a0a617d..b8f6a43e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,170 +1,50 @@ -use std::{sync::Arc, time::Duration}; +use crate::prelude::*; +use crate::utils::emailer::Emailer; -use crate::utils::{config::*, db::Db, email::Email}; -use tera::Tera; - -use anyhow::Result; -use axum::{ - extract::{MatchedPath, Query, Request, State}, - http::{header, StatusCode}, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, - Form, Router, -}; -use axum_extra::extract::CookieJar; -use lettre::message::Mailbox; -use tower_http::{services::ServeDir, trace::TraceLayer}; -use tracing::Span; +mod auth; +mod emails; +mod events; +mod home; +mod lists; +mod posts; +mod tickets; #[derive(Clone)] #[allow(unused)] -struct AppState { - config: Config, - templates: Tera, - db: Db, - mail: Email, +pub struct AppState { + pub config: Config, + pub db: Db, + pub mailer: Emailer, } -pub async fn build(config: Config) -> Result { - let state = AppState { +pub async fn build(config: Config) -> Result> { + let state = Arc::new(AppState { config: config.clone(), - templates: Tera::new("templates/*")?, - db: Db::connect(&config.app.db).await?, - mail: Email::connect(config.email).await?, - }; - - let router = Router::new() - .route("/", get(home)) - .route("/login", post(login_form)) - .route("/login", get(login)) - .route("/register", get(register)) - .route("/register", post(register_form)) - .nest_service("/assets", ServeDir::new("assets")) - .layer( - TraceLayer::new_for_http() - .make_span_with(|req: &Request<_>| { - let path = match req.extensions().get::() { - Some(path) => path.as_str(), - None => req.uri().path(), - }; - tracing::info_span!("request", method = ?req.method(), path, status = tracing::field::Empty) - }) - .on_request(|_req: &Request<_>, _span: &Span| {}) - .on_response(|res: &Response, latency: Duration, span: &Span| { - span.record("status", res.status().as_u16()); - tracing::info!("handled in {latency:?}"); - }), - ) - .with_state(Arc::new(state)); - Ok(router) -} - -async fn home(State(state): State>, cookies: CookieJar) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("message", "Hello, world!"); - - if let Some(session_token) = cookies.get("session") { - let Some(user) = state.db.lookup_user_from_session_token(session_token.value()).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - ctx.insert("user", &user); - } - - let html = state.templates.render("home.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct LoginForm { - email: Mailbox, -} -async fn login_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let login_token = state.db.create_login_token(&form.email).await?; - - let url = &state.config.app.url; - let url = match state.db.lookup_user_by_email(&form.email).await? { - Some(_) => format!("{url}/login?token={login_token}"), - None => format!("{url}/register?token={login_token}"), - }; - - let msg = state.mail.builder().to(form.email).body(url)?; - state.mail.send(msg).await?; - - Ok("Check your email!") -} - -#[derive(serde::Deserialize)] -struct LoginQuery { - token: String, -} -async fn login(State(state): State>, Query(login): Query) -> AppResult { - let Some(user) = state.db.lookup_user_by_login_token(&login.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let session_token = state.db.create_session_token(user.id).await?; - let headers = ( - // TODO: expiration date - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} - -#[derive(serde::Deserialize)] -struct RegisterQuery { - token: String, -} -async fn register( - State(state): State>, - Query(register): Query, -) -> AppResult { - let mut ctx = tera::Context::new(); - ctx.insert("token", ®ister.token); - let html = state.templates.render("register.tera.html", &ctx).unwrap(); - Ok(Html(html).into_response()) -} - -#[derive(serde::Deserialize)] -struct RegisterForm { - token: String, - first_name: String, - last_name: String, -} -async fn register_form( - State(state): State>, - Form(form): Form, -) -> AppResult { - let Some(email) = state.db.lookup_email_by_login_token(&form.token).await? else { - return Ok(StatusCode::FORBIDDEN.into_response()); - }; - - let user_id = state.db.create_user(&form.first_name, &form.last_name, &email).await?; - let session_token = state.db.create_session_token(user_id).await?; - - // TODO: expiration date on the cookie - let headers = ( - [(header::SET_COOKIE, format!("session={session_token}; Secure; Secure"))], - Redirect::to(&state.config.app.url), - ); - Ok(headers.into_response()) -} - -struct AppError(anyhow::Error); -type AppResult = Result; -impl IntoResponse for AppError { - fn into_response(self) -> Response { - // TODO: add a `dev` mode to `config.app`, and: - // * when enabled, respond with a stack trace - // * when disabled, respond with a generic error message that doesn't leak any details - (StatusCode::INTERNAL_SERVER_ERROR, format!("Error: {}", self.0)).into_response() - } -} -impl> From for AppError { - fn from(e: E) -> Self { - Self(e.into()) - } + db: crate::db::init(&config.db).await?, + mailer: Emailer::connect(config.email).await?, + }); + + // Register business logic routes + let r = AppRouter::new(&state); + let r = home::add_routes(r); + let r = auth::add_routes(r); + let r = posts::add_routes(r); + let r = events::add_routes(r); + let r = lists::add_routes(r); + let r = emails::add_routes(r); + let r: AppRouter = tickets::add_routes(r); + let (r, state) = r.finish(); + + // Register app-wide routes + let r = r.nest_service("/static", tower_http::services::ServeDir::new("frontend/static")); + // For non-HTML pages without a , this is where the browser looks + let r = r.route("/favicon.ico", get(|| async { Redirect::to("/static/favicon.ico") })); + let r = r.fallback(|| async { AppError::NotFound }); + + // Register middleware + let r = auth::add_middleware(r, Arc::clone(&state)); + let r = crate::utils::tracing::add_middleware(r); + let r = r.with_state(state); + + Ok(r) } diff --git a/src/app/posts.rs b/src/app/posts.rs new file mode 100644 index 00000000..9fe3eb43 --- /dev/null +++ b/src/app/posts.rs @@ -0,0 +1,271 @@ +use crate::db::list::{List, ListMember}; +use crate::db::post::{Post, UpdatePost}; +use crate::prelude::*; + +/// Add all `post` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router + .public_routes(|r| { + r.route("/p/{url}", get(read::view)) + .route("/posts/{url}", get(read::view)) + .route("/posts/{url}/preview", get(read::preview)) + }) + .restricted_routes(User::WRITER, |r| { + r.route("/posts", get(read::list)) + .route("/posts/new", get(edit::new)) + .route("/posts/{url}/edit", get(edit::edit_page).post(edit::edit_form)) + .route("/posts/{url}/send", get(send::page).post(send::form)) + .route("/posts/{url}/delete", post(edit::delete_form)) + }) +} + +/// View and list posts. +mod read { + use super::*; + + // Display a list of posts. + pub async fn list(State(state): State) -> AppResult { + let posts = Post::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/list.html")] + struct Html { + pub posts: Vec, + } + Ok(Html { posts }) + } + + // Display a single post. + pub async fn view( + State(state): State, + Path(url): Path, + ) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/view.html")] + struct Html { + pub post: Post, + } + Ok(Html { post }) + } + + // Display a preview of a post as it would appear in an email. + pub async fn preview( + State(state): State, + Path(url): Path, + ) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/email.html")] + struct Html { + pub post: Post, + pub opened_url: String, + pub unsub_url: String, + } + Ok(Html { post, opened_url: "".into(), unsub_url: "".into() }) + } +} + +/// Create and edit posts. +mod edit { + use super::*; + + // New post page. + pub async fn new() -> AppResult { + let post = Post { + id: 0, + title: "".into(), + url: "".into(), + author: "".into(), + content: "".into(), + created_at: Utc::now().naive_utc(), + updated_at: Utc::now().naive_utc(), + }; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/edit.html")] + pub struct Html { + pub post: Post, + } + Ok(Html { post }) + } + + // Edit post page. + pub async fn edit_page( + State(state): State, + Path(url): Path, + ) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/edit.html")] + pub struct Html { + pub post: Post, + } + Ok(Html { post }) + } + + // Edit post form. + #[derive(serde::Deserialize)] + pub struct EditForm { + id: Option, + #[serde(flatten)] + post: UpdatePost, + } + pub async fn edit_form( + State(state): State, + Form(form): Form, + ) -> AppResult { + match form.id { + Some(id) => Post::update(&state.db, id, &form.post).await?, + None => { + Post::create(&state.db, &form.post).await?; + } + } + + Ok(()) + } + + // Delete post form. + pub async fn delete_form( + State(state): State, + Path(url): Path, + ) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + Post::delete(&state.db, post.id).await?; + Ok(Redirect::to("/posts")) + } +} + +mod send { + use super::*; + + /// Display the form to send a post. + pub async fn page( + State(state): State, + Path(url): Path, + ) -> AppResult { + let Some(post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + let lists = List::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "posts/send.html")] + pub struct Html { + pub post: Post, + pub lists: Vec, + } + Ok(Html { post, lists }) + } + + #[derive(Template, WebTemplate)] + #[template(path = "posts/email.html")] + pub struct EmailHtml { + pub post: Post, + pub opened_url: String, + pub unsub_url: String, + } + + // Process the form and create or edit a post. + #[derive(serde::Deserialize)] + pub struct SendForm { + list_id: i64, + } + pub async fn form( + State(state): State, + Path(url): Path, + Form(form): Form, + ) -> AppResult { + let Some(mut post) = Post::lookup_by_url(&state.db, &url).await? else { + return Err(AppError::NotFound); + }; + let Some(list) = List::lookup_by_id(&state.db, form.list_id).await? else { + return Err(AppError::NotFound); + }; + let members = List::list_members(&state.db, form.list_id).await?; + + // XXX: The `url` field is just a slug, not an absolute URL. + // We can't yet access `config.app.url` within templates, so we just mutate + // the URL here and rely on that behavior in the `email.html` template. + post.url = format!("{}/p/{}", &state.config.app.url, &post.url); + let mut email_template = + EmailHtml { post: post.clone(), opened_url: "".into(), unsub_url: "".into() }; + + let mut num_sent = 0; + let mut num_skipped = 0; + let mut errors = HashMap::new(); + let batch_size = state.config.email.ratelimit.unwrap_or(members.len()); + for (i, members) in members.chunks(batch_size).enumerate() { + tracing::info!( + "Sending emails ({}..{} of {})", + i * batch_size + 1, + (i + 1) * batch_size + 1, + members.len() + ); + for ListMember { email, .. } in members { + // If this post was already sent to this address in this list, skip sending it again. + if Email::lookup_post(&state.db, email, post.id, list.id).await?.is_some() { + num_skipped += 1; + continue; + } + let email_id = Email::create_post(&state.db, email, post.id, list.id).await?; + + email_template.opened_url = format!("{}/emails/{email_id}/footer.gif", &state.config.app.url); + email_template.unsub_url = format!("{}/emails/{email_id}/unsubscribe", &state.config.app.url); + + use lettre::message::header::ContentType; + + let from = &state.config.email.from; + let reply_to = state.config.email.reply_to.as_ref().unwrap_or(from); + let msg = state + .mailer + .builder() + .to(email.parse().unwrap()) + .reply_to(reply_to.clone()) + .subject(&post.title) + .header(ContentType::TEXT_HTML) + .body(email_template.render()?) + .unwrap(); + + match state.mailer.send(msg).await { + Ok(_) => { + Email::mark_sent(&state.db, email_id).await?; + num_sent += 1; + } + Err(e) => { + tracing::error!("Sending email: {e:#}"); + let e = e.to_string(); + Email::mark_error(&state.db, email_id, &e).await?; + errors.insert(email.clone(), e); + } + } + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + tracing::info!("Successfully sent {} emails", members.len()); + + #[derive(Template, WebTemplate)] + #[template(path = "posts/sent.html")] + pub struct SentHtml { + pub post_title: String, + pub list_name: String, + pub num_sent: i64, + pub num_skipped: i64, + pub errors: HashMap, + } + Ok(SentHtml { post_title: post.title, list_name: list.name, num_sent, num_skipped, errors }) + } +} diff --git a/src/app/tickets.rs b/src/app/tickets.rs new file mode 100644 index 00000000..00430f41 --- /dev/null +++ b/src/app/tickets.rs @@ -0,0 +1,92 @@ +use crate::db::ticket::{Ticket, UpdateTicket}; +use crate::prelude::*; + +/// Add all `ticket` routes to the router. +#[rustfmt::skip] +pub fn add_routes(router: AppRouter) -> AppRouter { + router.restricted_routes(User::ADMIN, |r| { + r.route("/tickets", get(list_tickets_page)) + .route("/tickets/new", get(create_ticket_page).post(create_ticket_form)) + .route("/tickets/{id}", get(view_ticket_page).delete(delete_ticket)) + .route("/tickets/{id}/edit", get(edit_ticket_page).post(update_ticket_form)) + .route("/tickets/{id}/delete", post(delete_ticket), + ) + }) +} + +/// Display a list of all tickets. +async fn list_tickets_page(State(state): State) -> AppResult { + let tickets = Ticket::list(&state.db).await?; + + #[derive(Template, WebTemplate)] + #[template(path = "tickets/list.html")] + struct Html { + pub tickets: Vec, + } + Ok(Html { tickets }) +} + +/// Display the form to create a new ticket. +async fn create_ticket_page() -> impl IntoResponse { + #[derive(Template, WebTemplate)] + #[template(path = "tickets/create.html")] + struct Html; + Html +} + +/// Process the form and create a new ticket. +async fn create_ticket_form( + State(state): State, + Form(form): Form, +) -> AppResult { + let _ = Ticket::create(&state.db, &form).await?; + Ok(Redirect::to("/tickets")) +} + +async fn view_ticket_page( + State(state): State, + Path(id): Path, +) -> AppResult { + let ticket = Ticket::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + + #[derive(Template, WebTemplate)] + #[template(path = "tickets/view.html")] + pub struct Html { + pub ticket: Ticket, + } + Ok(Html { ticket }) +} + +/// Display the form to update a ticket. +async fn edit_ticket_page( + State(state): State, + Path(id): Path, +) -> AppResult { + let ticket = Ticket::lookup_by_id(&state.db, id).await?.ok_or(AppError::NotFound)?; + + #[derive(Template, WebTemplate)] + #[template(path = "tickets/edit.html")] + pub struct Html { + pub ticket: Ticket, + } + Ok(Html { ticket }) +} + +/// Process the form and update a ticket. +async fn update_ticket_form( + State(state): State, + Path(id): Path, + Form(form): Form, +) -> AppResult { + Ticket::update(&state.db, id, &form).await?; + Ok(Redirect::to(&format!("/tickets/{}", id))) +} + +/// Delete a ticket. +async fn delete_ticket( + State(state): State, + Path(id): Path, +) -> AppResult { + Ticket::delete(&state.db, id).await?; + Ok(Redirect::to("/tickets")) +} diff --git a/src/db/email.rs b/src/db/email.rs new file mode 100644 index 00000000..f349bb26 --- /dev/null +++ b/src/db/email.rs @@ -0,0 +1,109 @@ +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +/// A record of a an email which has been sent. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Email { + pub id: i64, + pub kind: String, + pub address: String, + pub post_id: Option, + pub list_id: Option, + pub user_id: Option, + pub event_id: Option, + pub error: Option, + pub created_at: NaiveDateTime, + pub sent_at: Option, + pub opened_at: Option, +} + +impl Email { + /// A login email. + pub const LOGIN: &'static str = "login"; + /// An email containing a post. + pub const POST: &'static str = "post"; + + /// Lookup an email by id. + pub async fn lookup(db: &Db, id: i64) -> AppResult> { + let res = sqlx::query_as!(Self, r#"SELECT * FROM emails WHERE id = ?"#, id) + .fetch_optional(db) + .await?; + Ok(res) + } + + /// Lookup an email by address, post, and list. + pub async fn lookup_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> AppResult> { + let res = sqlx::query_as!( + Self, + r#"SELECT * FROM emails + WHERE address = ? AND post_id = ? AND list_id = ?"#, + address, + post_id, + list_id + ) + .fetch_optional(db) + .await?; + Ok(res) + } + + /// Create a new email record. + pub async fn create_login(db: &Db, address: &str) -> AppResult { + let res = sqlx::query!("INSERT INTO emails (kind, address) VALUES (?, ?)", Email::LOGIN, address) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Create a new email record referencing another database entry. + pub async fn create_post(db: &Db, address: &str, post_id: i64, list_id: i64) -> AppResult { + let res = sqlx::query!( + r#"INSERT INTO emails (kind, address, post_id, list_id) VALUES (?, ?, ?, ?)"#, + Email::POST, + address, + post_id, + list_id + ) + .execute(db) + .await?; + Ok(res.last_insert_rowid()) + } + + /// Mark an email as sent. + pub async fn mark_sent(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP + WHERE id = ?"#, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as sent. + pub async fn mark_error(db: &Db, id: i64, error: &str) -> AppResult<()> { + sqlx::query!( + r#"UPDATE emails SET sent_at = CURRENT_TIMESTAMP, error = ? + WHERE id = ?"#, + error, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Mark an email as opened. + pub async fn mark_opened(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!( + r#"UPDATE emails SET opened_at = CURRENT_TIMESTAMP + WHERE id = ? AND opened_at IS NULL"#, + id + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/event.rs b/src/db/event.rs new file mode 100644 index 00000000..06d8ee08 --- /dev/null +++ b/src/db/event.rs @@ -0,0 +1,101 @@ +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Event { + pub id: i64, + pub guest_list_id: Option, + + pub title: String, + pub slug: String, + pub description: String, + pub flyer: Option, + + pub start: NaiveDateTime, + pub end: Option, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize, Debug, Clone)] +pub struct UpdateEvent { + pub guest_list_id: Option, + + pub title: String, + pub slug: String, + pub description: String, + pub flyer: Option, + + pub start: NaiveDateTime, + pub end: Option, +} + +impl Event { + // List all events. + pub async fn list(db: &Db) -> AppResult> { + let events = sqlx::query_as!(Self, "SELECT * FROM events").fetch_all(db).await?; + Ok(events) + } + + // Create a new event. + pub async fn create(db: &Db, event: &UpdateEvent) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO events + (title, slug, description, flyer, start, end, guest_list_id) + VALUES (?, ?, ?, ?, ?, ?, ?)"#, + event.title, + event.slug, + event.description, + event.flyer, + event.start, + event.end, + event.guest_list_id, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + // Update an event. + pub async fn update(db: &Db, id: i64, event: &UpdateEvent) -> AppResult<()> { + sqlx::query!( + r#"UPDATE events + SET title = ?, + slug = ?, + description = ?, + flyer = ?, + start = ?, + end = ?, + guest_list_id = ? + WHERE id = ?"#, + event.title, + event.slug, + event.description, + event.flyer, + event.start, + event.end, + event.guest_list_id, + id + ) + .execute(db) + .await?; + Ok(()) + } + + // Delete an event. + pub async fn delete(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!("DELETE FROM events WHERE id = ?", id).execute(db).await?; + Ok(()) + } + + // Lookup an event by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> AppResult> { + let event = sqlx::query_as!(Self, r#"SELECT * FROM events WHERE id = ?"#, id) + .fetch_optional(db) + .await?; + Ok(event) + } +} diff --git a/src/db/event_ticket.rs b/src/db/event_ticket.rs new file mode 100644 index 00000000..1de9b4be --- /dev/null +++ b/src/db/event_ticket.rs @@ -0,0 +1,94 @@ +use super::Db; +use crate::utils::error::AppResult; +use serde::Deserialize; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct EventTicket { + pub event_id: i64, + pub ticket_id: i64, + pub price: i64, + pub quantity: i64, + pub sort: i64, +} + +#[derive(Deserialize)] +pub struct UpdateEventTicket { + pub price: i64, + pub quantity: i64, + pub sort: i64, +} + +impl EventTicket { + /// List all event tickets for a given event. + pub async fn list_for_event(db: &Db, event_id: i64) -> AppResult> { + let tickets = sqlx::query_as!(Self, "SELECT * FROM event_tickets WHERE event_id = ?", event_id) + .fetch_all(db) + .await?; + Ok(tickets) + } + + /// Create a new event ticket association. + pub async fn create( + db: &Db, + event_id: i64, + ticket_id: i64, + price: i64, + quantity: i64, + sort: i64, + ) -> AppResult<()> { + sqlx::query!( + r#"INSERT INTO event_tickets (event_id, ticket_id, price, quantity, sort) + VALUES (?, ?, ?, ?, ?)"#, + event_id, + ticket_id, + price, + quantity, + sort, + ) + .execute(db) + .await?; + Ok(()) + } + + /// Update an existing event ticket association. + pub async fn update(db: &Db, event_id: i64, ticket_id: i64, update: &UpdateEventTicket) -> AppResult<()> { + sqlx::query!( + r#"UPDATE event_tickets + SET price = ?, quantity = ?, sort = ? + WHERE event_id = ? AND ticket_id = ?"#, + update.price, + update.quantity, + update.sort, + event_id, + ticket_id, + ) + .execute(db) + .await?; + Ok(()) + } + + /// Delete an event ticket association. + pub async fn delete(db: &Db, event_id: i64, ticket_id: i64) -> AppResult<()> { + sqlx::query!( + r#"DELETE FROM event_tickets WHERE event_id = ? AND ticket_id = ?"#, + event_id, + ticket_id, + ) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup an event ticket by its composite primary key (event_id, ticket_id). + pub async fn lookup_by_ids(db: &Db, event_id: i64, ticket_id: i64) -> AppResult> { + let ticket = sqlx::query_as!( + Self, + r#"SELECT * FROM event_tickets WHERE event_id = ? AND ticket_id = ?"#, + event_id, + ticket_id + ) + .fetch_optional(db) + .await?; + Ok(ticket) + } +} diff --git a/src/db/list.rs b/src/db/list.rs new file mode 100644 index 00000000..daa3e9b7 --- /dev/null +++ b/src/db/list.rs @@ -0,0 +1,121 @@ +use chrono::NaiveDateTime; +use sqlx::QueryBuilder; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct List { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct ListMember { + pub email: String, + pub first_name: Option, + pub last_name: Option, +} + +#[derive(serde::Deserialize)] +pub struct UpdateList { + pub id: Option, + pub name: String, + pub description: String, + pub emails: String, +} + +impl List { + /// List all lists. + pub async fn list(db: &Db) -> AppResult> { + let events = sqlx::query_as!(Self, "SELECT * FROM lists").fetch_all(db).await?; + Ok(events) + } + + /// Create a list. + pub async fn create(db: &Db, event: &UpdateList) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO lists + (name, description) + VALUES (?, ?)"#, + event.name, + event.description + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Update a list. + pub async fn update(db: &Db, id: i64, event: &UpdateList) -> AppResult<()> { + sqlx::query!( + r#"UPDATE lists + SET name = ?, description = ? + WHERE id = ?"#, + event.name, + event.description, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup a list by id, if one exists. + pub async fn lookup_by_id(db: &Db, id: i64) -> AppResult> { + let event = sqlx::query_as!( + Self, + r#"SELECT * + FROM lists + WHERE id = ?"#, + id + ) + .fetch_optional(db) + .await?; + Ok(event) + } + + /// Lookup the members of a list. + pub async fn list_members(db: &Db, list_id: i64) -> AppResult> { + let members = sqlx::query_as!( + ListMember, + r#"SELECT e.email, u.first_name, u.last_name + FROM list_members e + LEFT JOIN users u ON u.email = e.email + WHERE e.list_id = ? + ORDER BY e.created_at"#, + list_id + ) + .fetch_all(db) + .await?; + Ok(members) + } + + /// Add members to a guest list. + pub async fn add_members(db: &Db, list_id: i64, emails: &[&str]) -> AppResult<()> { + QueryBuilder::new("INSERT INTO list_members (list_id, email) ") + .push_values(emails, |mut b, email| { + b.push_bind(list_id).push_bind(email); + }) + .push("ON CONFLICT DO NOTHING") + .build() + .execute(db) + .await?; + Ok(()) + } + + pub async fn remove_member(db: &Db, list_id: i64, email: &str) -> AppResult<()> { + sqlx::query!( + r#"DELETE FROM list_members + WHERE list_id = ? AND email = ?"#, + list_id, + email + ) + .execute(db) + .await?; + Ok(()) + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 00000000..ace9c62c --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,78 @@ +use std::path::Path; + +use anyhow::Context as _; +use serde::Deserialize; +use sqlx::migrate::MigrateDatabase; +use sqlx::{Sqlite, SqlitePool}; +use user::User; + +use crate::utils::config::DbConfig; + +pub type Db = SqlitePool; + +pub mod email; +pub mod event; +pub mod event_ticket; +pub mod list; +pub mod notification; +pub mod post; +pub mod rsvp; +pub mod ticket; +pub mod token; +pub mod user; + +/// Create a new db connection pool, initializing and running migrations if necessary. +pub async fn init(db_config: &DbConfig) -> anyhow::Result { + let url = format!("sqlite://{}", db_config.file.display()); + if !Sqlite::database_exists(&url).await? { + Sqlite::create_database(&url).await?; + } + let db = SqlitePool::connect(&url).await?; + + sqlx::migrate!("./migrations").run(&db).await?; + + if let Some(seed_data) = &db_config.seed_data { + seed_db(&db, seed_data).await?; + } + + Ok(db) +} + +#[derive(Deserialize)] +struct SeedData { + users: Vec, + user_roles: Vec, +} + +impl SeedData { + pub async fn load(file: &Path) -> anyhow::Result { + let contents = tokio::fs::read_to_string(file).await?; + toml::from_str(&contents).with_context(|| format!("loading config={file:#?}")) + } +} + +async fn seed_db(db: &Db, seed_data_path: &Path) -> anyhow::Result<()> { + let seed_data = SeedData::load(seed_data_path).await?; + + for user in seed_data.users { + if User::lookup_by_email(db, &user.email).await?.is_none() { + User::create(db, &user).await?; + } + } + + for user_role in seed_data.user_roles { + if sqlx::query!( + "SELECT * FROM user_roles WHERE user_id = ? AND role = ?", + user_role.user_id, + user_role.role + ) + .fetch_optional(db) + .await? + .is_none() + { + User::add_role(db, user_role.user_id, &user_role.role).await?; + } + } + + Ok(()) +} diff --git a/src/db/notification.rs b/src/db/notification.rs new file mode 100644 index 00000000..3577db8f --- /dev/null +++ b/src/db/notification.rs @@ -0,0 +1,74 @@ +#![allow(unused)] + +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Notification { + pub id: i64, + /// Set when this notification is specific to an individual event, rather + /// than a generically applicable notification for a certain type of event. + pub event_id: Option, + + pub name: String, + pub content: String, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdateNotification { + pub event_id: Option, + pub name: String, + pub content: String, +} + +impl Notification { + pub async fn list(db: &Db) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM notifications"#).fetch_all(db).await?) + } + + pub async fn create(db: &Db, n: &UpdateNotification) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO notifications (event_id, name, content) + VALUES (?, ?, ?)"#, + n.event_id, + n.name, + n.content, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + pub async fn update(db: &Db, id: i64, n: &UpdateNotification) -> AppResult<()> { + sqlx::query!( + r#"UPDATE notifications + SET event_id = ?, name = ?, content = ? + WHERE id = ?"#, + n.event_id, + n.name, + n.content, + id + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!(r#"DELETE FROM notifications WHERE id = ?"#, id) + .execute(db) + .await?; + Ok(()) + } + + pub async fn lookup_by_id(db: &Db, id: i64) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM notifications WHERE id = ?"#, id) + .fetch_optional(db) + .await?) + } +} diff --git a/src/db/post.rs b/src/db/post.rs new file mode 100644 index 00000000..1aa960f3 --- /dev/null +++ b/src/db/post.rs @@ -0,0 +1,84 @@ +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Clone, Debug, sqlx::FromRow, serde::Serialize)] +pub struct Post { + pub id: i64, + pub title: String, + pub url: String, + pub author: String, + pub content: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(serde::Deserialize)] +pub struct UpdatePost { + pub title: String, + pub url: String, + pub author: String, + pub content: String, +} + +impl Post { + // List all posts. + pub async fn list(db: &Db) -> AppResult> { + let posts = sqlx::query_as!(Self, "SELECT * FROM posts ORDER BY updated_at DESC") + .fetch_all(db) + .await?; + Ok(posts) + } + + /// Create a new post. + pub async fn create(db: &Db, post: &UpdatePost) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO posts + (title, url, author, content) + VALUES (?, ?, ?, ?)"#, + post.title, + post.url, + post.author, + post.content, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Update an existing post. + pub async fn update(db: &Db, id: i64, post: &UpdatePost) -> AppResult<()> { + sqlx::query!( + r#"UPDATE posts + SET title = ?, + url = ?, + author = ?, + content = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?"#, + post.title, + post.url, + post.author, + post.content, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Delete a post. + pub async fn delete(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!("DELETE FROM posts WHERE id = ?", id).execute(db).await?; + Ok(()) + } + + /// Lookup a post by URL, if one exists. + pub async fn lookup_by_url(db: &Db, url: &str) -> AppResult> { + let row = sqlx::query_as!(Self, "SELECT * FROM posts WHERE url = ?", url) + .fetch_optional(db) + .await?; + Ok(row) + } +} diff --git a/src/db/rsvp.rs b/src/db/rsvp.rs new file mode 100644 index 00000000..85d0aa80 --- /dev/null +++ b/src/db/rsvp.rs @@ -0,0 +1,94 @@ +#![allow(unused)] + +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Rsvp { + pub id: i64, + pub user_id: i64, + pub event_id: i64, + pub ticket_id: i64, + + pub transaction_id: Option, + pub promo_id: Option, + pub refund_id: Option, + + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub arrived_at: Option, +} + +#[derive(serde::Deserialize)] +pub struct UpdateRsvp { + pub user_id: i64, + pub event_id: i64, + pub ticket_id: i64, + + pub transaction_id: Option, + pub promo_id: Option, + pub refund_id: Option, + + pub arrived_at: Option, +} + +impl Rsvp { + pub async fn list(db: &Db) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM rsvps"#).fetch_all(db).await?) + } + + pub async fn create(db: &Db, rsvp: &UpdateRsvp) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO rsvps + (user_id, event_id, ticket_id, transaction_id, promo_id, refund_id) + VALUES (?, ?, ?, ?, ?, ?)"#, + rsvp.user_id, + rsvp.event_id, + rsvp.ticket_id, + rsvp.transaction_id, + rsvp.promo_id, + rsvp.refund_id, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + pub async fn update(db: &Db, id: i64, rsvp: &UpdateRsvp) -> AppResult<()> { + sqlx::query!( + r#"UPDATE rsvps + SET user_id = ?, + event_id = ?, + ticket_id = ?, + transaction_id = ?, + promo_id = ?, + refund_id = ?, + arrived_at = ? + WHERE id = ?"#, + rsvp.user_id, + rsvp.event_id, + rsvp.ticket_id, + rsvp.transaction_id, + rsvp.promo_id, + rsvp.refund_id, + rsvp.arrived_at, + id + ) + .execute(db) + .await?; + Ok(()) + } + + pub async fn delete(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!(r#"DELETE FROM rsvps WHERE id = ?"#, id).execute(db).await?; + Ok(()) + } + + pub async fn lookup_by_id(db: &Db, id: i64) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM rsvps WHERE id = ?"#, id) + .fetch_optional(db) + .await?) + } +} diff --git a/src/db/ticket.rs b/src/db/ticket.rs new file mode 100644 index 00000000..db2ced6a --- /dev/null +++ b/src/db/ticket.rs @@ -0,0 +1,69 @@ +#![allow(unused)] + +use chrono::NaiveDateTime; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct Ticket { + pub id: i64, + pub name: String, + pub description: String, + pub created_at: NaiveDateTime, + pub updated_at: Option, +} + +#[derive(serde::Deserialize)] +pub struct UpdateTicket { + pub name: String, + pub description: String, +} + +impl Ticket { + /// List all tickets. + pub async fn list(db: &Db) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM tickets"#).fetch_all(db).await?) + } + + /// Create a new ticket. + pub async fn create(db: &Db, ticket: &UpdateTicket) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO tickets (name, description) + VALUES (?, ?)"#, + ticket.name, + ticket.description, + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + /// Update an existing ticket. + pub async fn update(db: &Db, id: i64, ticket: &UpdateTicket) -> AppResult<()> { + sqlx::query!( + r#"UPDATE tickets + SET name = ?, description = ? + WHERE id = ?"#, + ticket.name, + ticket.description, + id + ) + .execute(db) + .await?; + Ok(()) + } + + /// Remove a ticket. + pub async fn delete(db: &Db, id: i64) -> AppResult<()> { + sqlx::query!(r#"DELETE FROM tickets WHERE id = ?"#, id).execute(db).await?; + Ok(()) + } + + /// Retrieve a ticket (if any) by its id. + pub async fn lookup_by_id(db: &Db, id: i64) -> AppResult> { + Ok(sqlx::query_as!(Self, r#"SELECT * FROM tickets WHERE id = ?"#, id) + .fetch_optional(db) + .await?) + } +} diff --git a/src/db/token.rs b/src/db/token.rs new file mode 100644 index 00000000..1f67344c --- /dev/null +++ b/src/db/token.rs @@ -0,0 +1,58 @@ +use chrono::{DateTime, Utc}; +use rand::rngs::OsRng; +use rand::Rng; + +use super::Db; +use crate::utils::error::AppResult; + +/// A token which can be used to authenticate as a user. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct SessionToken { + pub id: i64, + pub user_id: i64, + pub token: String, + pub created_at: DateTime, +} + +/// A token which can be used to login or register. +#[derive(Debug, sqlx::FromRow, serde::Serialize)] +pub struct LoginToken { + pub id: i64, + pub email: String, + pub token: String, + pub created_at: DateTime, +} + +impl SessionToken { + /// Create a new session token for a user. + pub async fn create(db: &Db, user_id: i64) -> AppResult { + let token = format!("{:08x}", OsRng.gen::()); + + sqlx::query!("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)", user_id, token) + .execute(db) + .await?; + + Ok(token) + } +} + +impl LoginToken { + /// Create a new login token for an email address. + pub async fn create(db: &Db, email: &str) -> AppResult { + let token = format!("{:08x}", OsRng.gen::()); + + sqlx::query!("INSERT INTO login_tokens (email, token) VALUES (?, ?)", email, token) + .execute(db) + .await?; + + Ok(token) + } + + /// Lookup the email address for the given login token, if it's valid. + pub async fn lookup_email(db: &Db, token: &str) -> AppResult> { + let row = sqlx::query_scalar!("SELECT email FROM login_tokens WHERE token = ?", token) + .fetch_optional(db) + .await?; + Ok(row) + } +} diff --git a/src/db/user.rs b/src/db/user.rs new file mode 100644 index 00000000..d2ba4747 --- /dev/null +++ b/src/db/user.rs @@ -0,0 +1,109 @@ +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; + +use super::Db; +use crate::utils::error::AppResult; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct User { + pub id: i64, + pub first_name: Option, + pub last_name: Option, + pub email: String, + pub created_at: NaiveDateTime, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct UserRole { + pub user_id: i64, + pub role: String, + pub created_at: NaiveDateTime, +} + +#[derive(Debug, serde::Deserialize)] +pub struct UpdateUser { + pub first_name: String, + pub last_name: String, + pub email: String, +} + +impl User { + /// Full access to everything. + pub const ADMIN: &'static str = "admin"; + /// Can manage posts. + pub const WRITER: &'static str = "writer"; + + /// Create a new user. + pub async fn create(db: &Db, user: &UpdateUser) -> AppResult { + let row = sqlx::query!( + r#"INSERT INTO users + (first_name, last_name, email) + VALUES (?, ?, ?)"#, + user.first_name, + user.last_name, + user.email + ) + .execute(db) + .await?; + Ok(row.last_insert_rowid()) + } + + pub async fn add_role(db: &Db, user_id: i64, role: &str) -> AppResult<()> { + sqlx::query!(r#"INSERT INTO user_roles (user_id, role) VALUES (?, ?)"#, user_id, role) + .execute(db) + .await?; + Ok(()) + } + + /// Lookup a user by email address, if one exists. + pub async fn lookup_by_email(db: &Db, email: &str) -> AppResult> { + let row = sqlx::query_as!(Self, "SELECT * FROM users WHERE email = ?", email) + .fetch_optional(db) + .await?; + Ok(row) + } + /// Lookup a user by a login token, if it's valid. + pub async fn lookup_by_login_token(db: &Db, token: &str) -> AppResult> { + // Weird workaround for sqlx incorrectly inferring nullability for joins + // not sure why this is needed here and not below + // use the "!" syntax to force the column to be interpreted as non-null + // https://github.com/launchbadge/sqlx/issues/2127 + let row = sqlx::query_as!( + User, + r#"SELECT u.id as "id!", u.first_name as "first_name!", u.last_name as "last_name!", u.email as "email!", u.created_at as "created_at!" + FROM login_tokens t + JOIN users u on u.email = t.email + WHERE token = ?"#, + token + ) + .fetch_optional(db) + .await?; + Ok(row) + } + /// Lookup a user by a session token, if it's valid. + pub async fn lookup_by_session_token(db: &Db, token: &str) -> AppResult> { + let user = sqlx::query_as!( + Self, + r#"SELECT u.* + FROM session_tokens t + JOIN users u on u.id = t.user_id + WHERE token = ?"#, + token + ) + .fetch_optional(db) + .await?; + Ok(user) + } + + pub async fn has_role(&self, db: &Db, role: &str) -> AppResult { + let row = sqlx::query!( + "SELECT * FROM user_roles WHERE user_id = ? AND role = ? OR role = ?", + self.id, + role, + User::ADMIN, + ) + .fetch_optional(db) + .await?; + Ok(row.is_some()) + } +} diff --git a/src/main.rs b/src/main.rs index a9791970..c86523c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,44 @@ -use anyhow::{Context, Result}; +mod prelude; mod app; +mod db; mod utils; -use axum::{handler::HandlerWithoutStateExt, response::Redirect}; +use axum::handler::HandlerWithoutStateExt; +use axum::response::Redirect; use axum_server::tls_rustls::RustlsConfig; use futures::StreamExt; +use tracing::level_filters::LevelFilter; +use tracing::Level; +use tracing_subscriber::layer::SubscriberExt as _; +use tracing_subscriber::util::SubscriberInitExt as _; use utils::config::*; +use crate::prelude::*; + #[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt().init(); +async fn main() -> anyhow::Result<()> { + // TODO(sam) is it possible to filter the logs from ServeDir? + let log_filter = tracing_subscriber::filter::Targets::default() + .with_target("h2", LevelFilter::OFF) + .with_target("globset", LevelFilter::OFF) + .with_target("rustls", LevelFilter::OFF) + .with_default(Level::DEBUG); + + tracing_subscriber::fmt() + .pretty() + .with_target(true) + .with_line_number(true) + .with_max_level(Level::DEBUG) + .finish() + .with(log_filter) + .try_init()?; // Load the server config - let file = std::env::args().nth(1).context("usage: wlsd ")?; + let file = std::env::args().nth(1).context("usage: lsd ")?; let config = Config::load(&file).await?; + // Make it visible to our HTML templates + utils::templates::CONFIG.set(config.clone()).unwrap(); let app = app::build(config.clone()).await?.into_make_service(); tracing::info!("Live at {}", &config.app.url); @@ -42,7 +66,7 @@ async fn main() -> Result<()> { tokio::spawn(async move { loop { match acme.next().await.unwrap() { - Ok(ok) => tracing::info!("acme: {:?}", ok), + Ok(ok) => tracing::debug!("acme: {:?}", ok), Err(err) => tracing::error!("acme: {}", err), } } diff --git a/src/prelude.rs b/src/prelude.rs new file mode 100644 index 00000000..1c7769b1 --- /dev/null +++ b/src/prelude.rs @@ -0,0 +1,27 @@ +pub use std::collections::HashMap; +pub use std::convert::Infallible; +pub use std::sync::Arc; +pub use std::time::Duration; + +pub use anyhow::{Context as _, Result}; +pub use askama::Template; +pub use askama_web::WebTemplate; +pub use axum::extract::{Path, Query, Request, State}; +pub use axum::http::{header, StatusCode}; +pub use axum::middleware::Next; +pub use axum::response::{IntoResponse, Redirect, Response}; +pub use axum::routing::{delete, get, post}; +pub use axum::Form; +pub use axum::Json; +pub use base64::prelude::*; +pub use chrono::Utc; + +pub use crate::db::email::Email; +pub use crate::db::user::User; +pub use crate::db::Db; +pub use crate::utils::config::Config; +pub use crate::utils::error::{AppError, AppResult}; +pub use crate::utils::image::{delete_image, process_image}; +pub use crate::utils::routing::{AppRouter, AxumRouter}; +pub use crate::utils::templates::filters; +pub use crate::utils::types::SharedAppState; diff --git a/src/utils/config.rs b/src/utils/config.rs index acbdceb5..a8647733 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1,10 +1,14 @@ -use anyhow::{Context, Result}; +use std::net::SocketAddr; +use std::path::PathBuf; + +use anyhow::Context as _; +use chrono_tz::Tz; use lettre::message::Mailbox; -use std::{net::SocketAddr, path::PathBuf}; impl Config { - pub async fn load(file: &str) -> Result { - async fn load_inner(file: &str) -> Result { + /// Load a `.toml` file from disk and parse it as a [`Config`]. + pub async fn load(file: &str) -> anyhow::Result { + async fn load_inner(file: &str) -> anyhow::Result { let contents = tokio::fs::read_to_string(file).await?; Ok(toml::from_str(&contents)?) } @@ -12,39 +16,70 @@ impl Config { } } +/// Bag of app configuration values, parsed from a TOML file with serde. #[derive(Clone, Debug, serde::Deserialize)] pub struct Config { pub app: AppConfig, + pub db: DbConfig, pub net: NetConfig, pub acme: Option, pub email: EmailConfig, } +/// Webapp configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AppConfig { + /// Public facing domain, e.g. `site.com`. + pub domain: String, + /// Public facing URL, e.g. `https://site.com`. pub url: String, - pub db: PathBuf, + /// Local timezone. + pub tz: Tz, +} + +/// Database configuration. +#[derive(Clone, Debug, serde::Deserialize)] +pub struct DbConfig { + /// Path to sqlite3 database file. + pub file: PathBuf, + pub seed_data: Option, } +/// Networking configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct NetConfig { + /// HTTP server bind address. pub http_addr: SocketAddr, + /// HTTS server bind address. pub https_addr: SocketAddr, } /// LetsEncrypt ACME TLS certificate configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct AcmeConfig { + /// Domain to request a cert for. pub domain: String, + /// Contact email. pub email: String, - /// Directory where certificates and credentials are stored. + /// Directory to store certs and credentials in. pub dir: String, /// Whether to use the production or staging ACME server. pub prod: bool, } +/// Email configuration. #[derive(Clone, Debug, serde::Deserialize)] pub struct EmailConfig { - pub addr: String, + /// SMTP address, starting with `smtp://`. + pub smtp_addr: String, + /// SMTP username. + pub smtp_username: Option, + /// SMTP password. + pub smtp_password: Option, + /// Mailbox to send email from. pub from: Mailbox, + /// Mailbox to put as ReplyTo. + pub reply_to: Option, + /// Maximum number of emails to send per second. + pub ratelimit: Option, } diff --git a/src/utils/db.rs b/src/utils/db.rs deleted file mode 100644 index 811c5aa0..00000000 --- a/src/utils/db.rs +++ /dev/null @@ -1,144 +0,0 @@ -use std::path::Path; - -use anyhow::Result; -use lettre::message::Mailbox; -use rand::{rngs::OsRng, Rng as _}; -use sqlx::{migrate::MigrateDatabase, Sqlite, SqlitePool}; - -#[derive(Clone)] -pub struct Db { - pool: SqlitePool, -} - -#[derive(sqlx::FromRow, serde::Serialize)] -pub struct User { - pub id: i64, - pub first_name: String, - pub last_name: String, - pub email: String, - pub created_at: String, -} - -impl Db { - pub async fn connect(file: &Path) -> Result { - let url = format!("sqlite://{}", file.display()); - if !Sqlite::database_exists(&url).await? { - Sqlite::create_database(&url).await?; - } - let pool = SqlitePool::connect(&url).await?; - - let db = Self { pool }; - db.migrate().await?; - Ok(db) - } - - async fn migrate(&self) -> Result<()> { - sqlx::query( - "CREATE TABLE IF NOT EXISTS users ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - first_name TEXT NOT NULL, \ - last_name TEXT NOT NULL, \ - email TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS login_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - email TEXT NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP \ - )", - ) - .execute(&self.pool) - .await?; - - sqlx::query( - "CREATE TABLE IF NOT EXISTS session_tokens ( \ - id INTEGER PRIMARY KEY NOT NULL, \ - user_id INTEGER NOT NULL, \ - token TEXT NOT NULL, \ - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, \ - FOREIGN KEY (user_id) REFERENCES users(id) \ - )", - ) - .execute(&self.pool) - .await?; - - Ok(()) - } - - pub async fn create_user(&self, first_name: &str, last_name: &str, email: &str) -> Result { - let row = sqlx::query("INSERT INTO users (first_name, last_name, email) VALUES (?, ?, ?)") - .bind(first_name) - .bind(last_name) - .bind(email) - .execute(&self.pool) - .await?; - Ok(row.last_insert_rowid()) - } - pub async fn lookup_user_by_email(&self, email: &Mailbox) -> Result> { - let row = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = ?") - .bind(email.email.to_string()) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM login_tokens t \ - LEFT JOIN users u on u.email = t.email \ - WHERE t.token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row) - } - pub async fn lookup_user_from_session_token(&self, token: &str) -> Result> { - let user = sqlx::query_as::<_, User>( - "SELECT u.* \ - FROM session_tokens t \ - JOIN users u on u.id = t.user_id \ - WHERE token = ?", - ) - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(user) - } - - pub async fn create_session_token(&self, user_id: i64) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO session_tokens (user_id, token) VALUES (?, ?)") - .bind(user_id) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn create_login_token(&self, email: &Mailbox) -> Result { - let token = format!("{:08x}", OsRng.gen::()); - - sqlx::query("INSERT INTO login_tokens (email, token) VALUES (?, ?)") - .bind(email.email.to_string()) - .bind(&token) - .execute(&self.pool) - .await?; - - Ok(token) - } - pub async fn lookup_email_by_login_token(&self, token: &str) -> Result> { - let row = sqlx::query_as::<_, (String,)>("SELECT email FROM login_tokens WHERE token = ?") - .bind(token) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| r.0)) - } -} diff --git a/src/utils/email.rs b/src/utils/email.rs deleted file mode 100644 index 9ffe592c..00000000 --- a/src/utils/email.rs +++ /dev/null @@ -1,31 +0,0 @@ -use anyhow::Result; -use lettre::{ - message::{header::ContentType, Mailbox, MessageBuilder}, - Message, SmtpTransport, Transport, -}; - -use crate::EmailConfig; - -#[derive(Clone)] -pub struct Email { - addr: String, - from: Mailbox, -} - -impl Email { - pub async fn connect(config: EmailConfig) -> Result { - // we need this for smtps - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - Ok(Self { addr: config.addr, from: config.from }) - } - - pub fn builder(&self) -> MessageBuilder { - Message::builder().from(self.from.clone()).header(ContentType::TEXT_PLAIN) - } - - pub async fn send(&self, message: Message) -> Result<()> { - let transport = SmtpTransport::from_url(&self.addr)?.build(); - transport.send(&message)?; - Ok(()) - } -} diff --git a/src/utils/emailer.rs b/src/utils/emailer.rs new file mode 100644 index 00000000..91483a28 --- /dev/null +++ b/src/utils/emailer.rs @@ -0,0 +1,39 @@ +use lettre::message::{Mailbox, MessageBuilder}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; + +use super::error::AppResult; +use crate::EmailConfig; + +/// Email client. +#[derive(Clone)] +pub struct Emailer { + /// Mailbox to send email from. + from: Mailbox, + /// Underlying SMTPS transport. + transport: SmtpTransport, +} + +impl Emailer { + pub async fn connect(config: EmailConfig) -> anyhow::Result { + // `lettre` requires a default provider to be installed to use SMTPS. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let mut transport = SmtpTransport::from_url(&config.smtp_addr)?; + if let (Some(username), Some(password)) = (config.smtp_username, config.smtp_password) { + transport = transport.credentials(Credentials::new(username, password)); + } + let transport = transport.build(); + + Ok(Self { transport, from: config.from }) + } + + pub fn builder(&self) -> MessageBuilder { + Message::builder().from(self.from.clone()) + } + + pub async fn send(&self, message: Message) -> AppResult<()> { + self.transport.send(&message)?; + Ok(()) + } +} diff --git a/src/utils/error.rs b/src/utils/error.rs new file mode 100644 index 00000000..599247bc --- /dev/null +++ b/src/utils/error.rs @@ -0,0 +1,63 @@ +use crate::prelude::*; + +/// App-wide result type which automatically handles conversion to an HTTP response. +pub type AppResult = Result; + +#[derive(thiserror::Error, Debug)] +pub enum AppError { + #[error("not found")] + NotFound, + #[error("not authorized")] + NotAuthorized, + #[error("Validation error: {0}")] + ValidationError(String), + #[error("Internal server error: {0}")] + InternalError(String), + + #[error(transparent)] + Email(#[from] lettre::error::Error), + #[error(transparent)] + Smtp(#[from] lettre::transport::smtp::Error), + #[error(transparent)] + Render(#[from] askama::Error), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +/// Convert an [`AppError`] into an HTTP response. +/// +/// This allows us to return `AppResult from `axum::Handler` functions, and +/// tells the framework how to deal with errors. +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("{self:#}"); + + let error_400 = || (StatusCode::BAD_REQUEST, "Invalid request"); + let error_401 = || (StatusCode::UNAUTHORIZED, "You do not have permission to view this page"); + let error_404 = || (StatusCode::NOT_FOUND, "Page not found"); + let error_500 = || (StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong :( Please try again"); + + let (status, message) = match self { + AppError::NotAuthorized => error_401(), + AppError::NotFound => error_404(), + AppError::ValidationError(_) => error_400(), + AppError::InternalError(_) => error_500(), + AppError::Database(_) => error_500(), + AppError::Smtp(_) => error_500(), + AppError::Email(_) => error_500(), + AppError::Render(_) => error_500(), + }; + + // TODO: add a `dev` mode to `config.app`, and: + // * when enabled, respond with a stack trace + // * when disabled, respond with a generic error message that doesn't leak any details + #[derive(Template, WebTemplate)] + #[template(path = "error.html")] + pub struct Html { + pub message: String, + } + let html = Html { message: message.to_string() }; + + (status, html).into_response() + } +} diff --git a/src/utils/image.rs b/src/utils/image.rs new file mode 100644 index 00000000..fbe27c2a --- /dev/null +++ b/src/utils/image.rs @@ -0,0 +1,106 @@ +use crate::prelude::*; +use std::path::Path; +use tokio::fs; + +/// Process image file: validate, resize, and save. +pub async fn process_image(base64_data: &str, filename: &Option) -> AppResult { + // Decode base64 data + let image_bytes = BASE64_STANDARD + .decode(base64_data) + .map_err(|e| AppError::ValidationError(format!("Invalid base64 image: {}", e)))?; + + //TODO: Temporarily commented out for testing + // Validate image size + // const MAX_IMAGE_SIZE: usize = 5 * 1024 * 1024; //5MB + // if image_bytes.len() > MAX_IMAGE_SIZE { + // return Err(AppError::ValidationError("Image size exceeds 5MB limit".to_string())); + // } + + //Validate image by checking magic bytes + if !is_valid_image(&image_bytes) { + return Err(AppError::ValidationError("Invalid image format".to_string())); + } + + //Generate a unique filename if not provided + let file_extension = get_file_extension(filename.as_deref(), &image_bytes); + let unique_img_filename = format!( + "event_{}.{}.{}", + Utc::now().timestamp(), + uuid::Uuid::new_v4().to_string()[..8].to_string(), + file_extension + ); + + // Create directory for uploaded images if it doesn't exist + let uploads_dir = Path::new("frontend/static/uploads/images/events"); + fs::create_dir_all(uploads_dir) + .await + .map_err(|e| AppError::InternalError(format!("Failed to create uploads directory: {}", e)))?; + + // Save the original image file + let image_path = uploads_dir.join(&unique_img_filename); + fs::write(&image_path, &image_bytes) + .await + .map_err(|e| AppError::InternalError(format!("Failed to save image:{}", e)))?; + + // TODO: Thumbnail creation and resizing + + Ok(format!("/static/uploads/images/events/{}", unique_img_filename)) +} + +/// Validate image by checking magic bytes - file signatures +fn is_valid_image(bytes: &[u8]) -> bool { + if bytes.len() < 4 { + return false; + } + + // Check for common image formats + match &bytes[0..4] { + [0xFF, 0xD8, 0xFF, ..] => true, // JPEG + [0x89, 0x50, 0x4E, 0x47] => true, // PNG + [0x47, 0x49, 0x46, 0x38] => true, // GIF + [0x52, 0x49, 0x46, 0x46] => { + // WebP (RIFF container) + bytes.len() >= 12 && &bytes[8..12] == b"WEBP" + } + _ => false, + } +} + +/// Get appropriate file extension based on filename or image content +fn get_file_extension(filename: Option<&str>, bytes: &[u8]) -> String { + // Try to get extension from filename first + if let Some(fname) = filename { + if let Some(ext) = fname.split('.').last() { + match ext.to_lowercase().as_str() { + "jpg" | "jpeg" | "png" | "gif" | "webp" => return ext.to_lowercase(), + _ => {} + } + } + } + + // Fall back to detecting from content + if bytes.len() >= 4 { + match &bytes[0..4] { + [0xFF, 0xD8, 0xFF, ..] => "jpg".to_string(), + [0x89, 0x50, 0x4E, 0x47] => "png".to_string(), + [0x47, 0x49, 0x46, 0x38] => "gif".to_string(), + [0x52, 0x49, 0x46, 0x46] => "webp".to_string(), + _ => "jpg".to_string(), // Default fallback + } + } else { + "jpg".to_string() + } +} + +/// Delete an image file from disk +pub async fn delete_image(file_path: &str) -> AppResult<()> { + if file_path.starts_with("/static/uploads/") { + let full_path = format!("frontend{}", file_path); // Convert web path to file path + if Path::new(&full_path).exists() { + fs::remove_file(&full_path) + .await + .map_err(|e| AppError::InternalError(format!("Failed to delete image: {}", e)))?; + } + } + Ok(()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 1badade9..b2524811 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,8 @@ pub mod config; -pub mod db; -pub mod email; +pub mod emailer; +pub mod error; +pub mod image; +pub mod routing; +pub mod templates; +pub mod tracing; +pub mod types; diff --git a/src/utils/routing.rs b/src/utils/routing.rs new file mode 100644 index 00000000..59334942 --- /dev/null +++ b/src/utils/routing.rs @@ -0,0 +1,65 @@ +use axum::http::request::Parts; + +use crate::prelude::*; + +pub type AxumRouter = axum::Router; + +/// A wrapper around the axum router and the shared state, with some additional helpers. +pub struct AppRouter { + router: AxumRouter, + state: SharedAppState, +} + +impl AppRouter { + /// Create a new empty `AppRouter`. + pub fn new(state: &SharedAppState) -> Self { + Self { router: Default::default(), state: Arc::clone(state) } + } + + pub fn finish(self) -> (AxumRouter, SharedAppState) { + (self.router, self.state) + } + + /// Add some public routes. + pub fn public_routes(mut self, func: impl FnOnce(AxumRouter) -> AxumRouter) -> Self { + let subrouter = func(AxumRouter::new()); + self.router = self.router.merge(subrouter); + self + } + + /// Add some routes which require authorization and a specific role. + pub fn restricted_routes( + mut self, + role: &'static str, + func: impl FnOnce(AxumRouter) -> AxumRouter, + ) -> Self { + let subrouter = func(AxumRouter::new()); + let subrouter = subrouter.route_layer(axum::middleware::from_fn_with_state( + self.state.clone(), + move |State(state): State, user: User, req: Request, next: Next| async move { + if !user.has_role(&state.db, role).await? { + return Err(AppError::NotAuthorized); + } + Ok(next.run(req).await) + }, + )); + self.router = self.router.merge(subrouter); + self + } +} + +/// Enable extracting an `Option` in a handler. +impl axum::extract::OptionalFromRequestParts for User { + type Rejection = Infallible; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result, Self::Rejection> { + Ok(parts.extensions.get::().cloned()) + } +} +/// Enable extracting a `User` in a handler, returning UNAUTHORIZED if not logged in. +impl axum::extract::FromRequestParts for User { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let user = parts.extensions.get::().cloned().ok_or(AppError::NotAuthorized)?; + Ok(user) + } +} diff --git a/src/utils/templates.rs b/src/utils/templates.rs new file mode 100644 index 00000000..2b8d23b9 --- /dev/null +++ b/src/utils/templates.rs @@ -0,0 +1,37 @@ +use std::sync::OnceLock; + +use chrono::NaiveDateTime; + +/// Global app config for reference from askama filters, set once at startup. +pub static CONFIG: OnceLock = OnceLock::new(); + +/// Askama implicitly looks for a `filters` module to be in the same scope as +/// the `#[derive(Template)]` to provide extra functions to templates. +/// +/// We export this along with `Template` in `crate::prelude`, so it should always properly be in scope. +pub mod filters { + use super::*; + + /// Format a datetime with a `strftime` format string. + pub fn format_datetime(dt: &NaiveDateTime, format: &str) -> Result { + let tz = CONFIG.get().unwrap().app.tz; + + let fmt = dt.and_utc().with_timezone(&tz).format(format); + Ok(fmt.to_string()) + } + + /// Convert Option to empty string if None + pub fn unwrap_or_empty(value: &Option) -> askama::Result { + Ok(value.as_deref().unwrap_or("").to_string()) + } + + /// Check if Option has a value (not None and not empty) + pub fn has_value(value: &Option) -> askama::Result { + Ok(value.as_ref().map_or(false, |s| !s.is_empty())) + } + + /// Get the length of a vector + pub fn length(value: &Vec) -> askama::Result { + Ok(value.len()) + } +} diff --git a/src/utils/tracing.rs b/src/utils/tracing.rs new file mode 100644 index 00000000..5dfc3077 --- /dev/null +++ b/src/utils/tracing.rs @@ -0,0 +1,32 @@ +use axum::http::Request; +use tower::ServiceBuilder; +use tower_http::request_id::{MakeRequestId, RequestId}; +use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}; +use tower_http::ServiceBuilderExt as _; +use uuid::Uuid; + +use crate::prelude::*; + +#[derive(Clone, Copy)] +pub struct MakeRequestUuidV7; +impl MakeRequestId for MakeRequestUuidV7 { + fn make_request_id(&mut self, _request: &Request) -> Option { + // Use UUIDv7 so that request ID can be sorted by time + let request_id = Uuid::now_v7(); + Some(RequestId::new(request_id.to_string().parse().unwrap())) + } +} + +/// Register tracing-related middleware into the router. +pub fn add_middleware(router: AxumRouter) -> AxumRouter { + router.layer( + ServiceBuilder::new() + .set_x_request_id(MakeRequestUuidV7) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().include_headers(true)) + .on_response(DefaultOnResponse::new().include_headers(true)), + ) + .propagate_x_request_id(), + ) +} diff --git a/src/utils/types.rs b/src/utils/types.rs new file mode 100644 index 00000000..de7bd093 --- /dev/null +++ b/src/utils/types.rs @@ -0,0 +1,5 @@ +use std::sync::Arc; + +/// The global shared application state. +pub use crate::app::AppState; +pub type SharedAppState = Arc; diff --git a/templates/home.tera.html b/templates/home.tera.html deleted file mode 100644 index cb9616c0..00000000 --- a/templates/home.tera.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - WLSD - - - -
-

{{ message }}

- {% if user %} -

Welcome, {{ user.first_name }} {{ user.last_name }}

- {% else %} -
- - - -
- {% endif %} -
- - - diff --git a/templates/register.tera.html b/templates/register.tera.html deleted file mode 100644 index c9661e31..00000000 --- a/templates/register.tera.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - WLSD - - - -
-

Register

-
- - - - - - - - -
-
- - diff --git a/templates/wlsd.tera copy.html b/templates/wlsd.tera copy.html deleted file mode 100644 index b5fa688b..00000000 --- a/templates/wlsd.tera copy.html +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - WLSD - - - -
-

{{ message }}

-
- - -