mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Compare commits
3 Commits
feat/auth-
...
2b8a03148f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8a03148f | ||
|
|
9581435578 | ||
|
|
4105914633 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -507,3 +507,4 @@ web/test-results/frontend/playwright-storybook.xml
|
|||||||
|
|
||||||
# mkdocs build output
|
# mkdocs build output
|
||||||
/site
|
/site
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ no data flowing over them.
|
|||||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
||||||
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
||||||
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
|
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod` (both external, so `down -v` keeps them), plus `nuget-cache-prod`. Startup order matches dev: SQL Server (health check) → migrations → seed → API → frontend, with `frontend` the only service publishing a host port. |
|
||||||
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
||||||
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
||||||
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
||||||
|
|||||||
@@ -137,24 +137,56 @@ containers, which would end the run prematurely. See
|
|||||||
- Health checks enabled
|
- Health checks enabled
|
||||||
- Restart policies (unless-stopped)
|
- Restart policies (unless-stopped)
|
||||||
- Security hardening
|
- Security hardening
|
||||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
|
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
||||||
|
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
||||||
reachable only from other containers on `prodnet`
|
reachable only from other containers on `prodnet`
|
||||||
|
- Seeded catalogue, with its AI-generated origin disclosed in the app. See
|
||||||
|
[Production data](#production-data) below.
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sqlserver # Production SQL Server (internal only)
|
sqlserver # Production SQL Server (internal only)
|
||||||
database.migrations # Schema updates only
|
database.migrations # Schema updates only
|
||||||
|
database.seed # Loads the catalogue, runs once
|
||||||
api.core # Production API (internal only, no published ports)
|
api.core # Production API (internal only, no published ports)
|
||||||
frontend # Production website (port 3000, the only public binding)
|
seaweedfs # S3-compatible object storage (internal only)
|
||||||
|
frontend # Production website (127.0.0.1:5656, the only host binding)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Deploy Production**:
|
**Deploy Production**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# One time, before the first `up`: the data volumes are external, so Compose
|
||||||
|
# expects them to exist already.
|
||||||
|
docker volume create biergartendata-prod
|
||||||
|
docker volume create seaweedfsdata-prod
|
||||||
|
|
||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Docker seeds each volume with the ownership the image expects and labels it for
|
||||||
|
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
|
||||||
|
enforcing distributions such as RHEL, AlmaLinux and Fedora.
|
||||||
|
|
||||||
|
#### Production data
|
||||||
|
|
||||||
|
Production runs `database.seed`, so the catalogue it serves is the AI-generated
|
||||||
|
fixture data produced by the pipeline. Per
|
||||||
|
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
|
||||||
|
anyone interacting with an application seeded from it has to be told upfront that
|
||||||
|
the content is AI-generated.
|
||||||
|
|
||||||
|
The website meets that obligation with `AiDisclosureDialog`
|
||||||
|
(`web/frontend/app/features/disclosure/components/AiDisclosureDialog.tsx`), which
|
||||||
|
the root route renders on every full page load, whichever route the visitor lands
|
||||||
|
on. It is part of the server-rendered HTML rather than mounted after hydration, so
|
||||||
|
the disclosure is on screen with the first paint. Acknowledging it hides the
|
||||||
|
dialog for the remainder of that page load; a new load shows it again.
|
||||||
|
|
||||||
|
Keep `database.seed` and the dialog together. A production deployment that seeds
|
||||||
|
the catalogue without the disclosure does not satisfy the ethics documentation.
|
||||||
|
|
||||||
### 4. Database only (`docker-compose.db.yaml`)
|
### 4. Database only (`docker-compose.db.yaml`)
|
||||||
|
|
||||||
**Purpose**: Run just the database for local backend development outside Docker
|
**Purpose**: Run just the database for local backend development outside Docker
|
||||||
@@ -255,9 +287,12 @@ api.core:
|
|||||||
|
|
||||||
**Production**:
|
**Production**:
|
||||||
|
|
||||||
- `sqlserverdata-prod` - Production database files
|
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||||
|
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||||
- `nuget-cache-prod` - Production NuGet cache
|
- `nuget-cache-prod` - Production NuGet cache
|
||||||
|
|
||||||
|
See [Production data volumes](#production-data-volumes).
|
||||||
|
|
||||||
### Mounted volumes
|
### Mounted volumes
|
||||||
|
|
||||||
**Test Results**:
|
**Test Results**:
|
||||||
@@ -269,6 +304,40 @@ volumes:
|
|||||||
|
|
||||||
Test results are written to host filesystem for CI/CD integration.
|
Test results are written to host filesystem for CI/CD integration.
|
||||||
|
|
||||||
|
### Production data volumes
|
||||||
|
|
||||||
|
`biergartendata-prod` and `seaweedfsdata-prod` are declared `external: true`.
|
||||||
|
Compose removes only the volumes it owns, so `docker compose down -v` tears the
|
||||||
|
stack down and leaves the database and the uploaded photos in place. Deleting
|
||||||
|
them takes an explicit `docker volume rm`.
|
||||||
|
|
||||||
|
The trade-off is that Compose creates neither one, and `up` fails with a clear
|
||||||
|
error until both exist — hence the `docker volume create` step in
|
||||||
|
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
|
||||||
|
carry their literal name rather than a `<project>_` prefix, which is why
|
||||||
|
`docker volume ls` shows `biergartendata-prod` next to `web_nuget-cache-prod`.
|
||||||
|
|
||||||
|
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
|
||||||
|
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
|
||||||
|
`/var/lib/docker`.
|
||||||
|
|
||||||
|
Back up the database through SQL Server rather than by copying its files, since a
|
||||||
|
file copy taken while the engine is running can be torn:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
|
||||||
|
-S localhost -U sa -P '<db-password>' -C \
|
||||||
|
-Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
|
||||||
|
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .
|
||||||
|
```
|
||||||
|
|
||||||
|
Object storage can be archived directly, with the stack stopped:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
|
||||||
|
alpine tar czf /backup/seaweedfs.tar.gz -C /data .
|
||||||
|
```
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
Each environment uses isolated bridge networks:
|
Each environment uses isolated bridge networks:
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ services:
|
|||||||
container_name: prod-env-sqlserver
|
container_name: prod-env-sqlserver
|
||||||
environment:
|
environment:
|
||||||
ACCEPT_EULA: "Y"
|
ACCEPT_EULA: "Y"
|
||||||
SA_PASSWORD: "${DB_PASSWORD}"
|
MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
MSSQL_PID: "Express"
|
MSSQL_PID: "Express"
|
||||||
volumes:
|
volumes:
|
||||||
- sqlserverdata-prod:/var/opt/mssql
|
- biergartendata-prod:/var/opt/mssql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -46,13 +46,44 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: database.seed
|
||||||
|
container_name: prod-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_started
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
api.core:
|
api.core:
|
||||||
env_file: ".env.prod"
|
env_file: ".env.prod"
|
||||||
image: api.core
|
image: api.core
|
||||||
container_name: prod-env-api-core
|
container_name: prod-env-api-core
|
||||||
depends_on:
|
depends_on:
|
||||||
sqlserver:
|
database.seed:
|
||||||
condition: service_healthy
|
condition: service_completed_successfully
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_started
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: API/API.Core/Dockerfile
|
dockerfile: API/API.Core/Dockerfile
|
||||||
@@ -73,12 +104,32 @@ services:
|
|||||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
volumes:
|
volumes:
|
||||||
- nuget-cache-prod:/root/.nuget/packages
|
- nuget-cache-prod:/root/.nuget/packages
|
||||||
|
|
||||||
|
seaweedfs:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: chrislusf/seaweedfs
|
||||||
|
container_name: prod-env-seaweedfs
|
||||||
|
# No published ports: the S3 API, filer, master and admin UIs are reachable
|
||||||
|
# only from other containers on prodnet.
|
||||||
|
environment:
|
||||||
|
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
|
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
|
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
volumes:
|
||||||
|
- seaweedfsdata-prod:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
env_file: ".env.prod"
|
env_file: ".env.prod"
|
||||||
image: frontend
|
image: frontend
|
||||||
@@ -90,10 +141,10 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "127.0.0.1:5656:5656"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
PORT: "3000"
|
PORT: "5656"
|
||||||
API_BASE_URL: "http://api.core:8080"
|
API_BASE_URL: "http://api.core:8080"
|
||||||
SESSION_SECRET: "${SESSION_SECRET}"
|
SESSION_SECRET: "${SESSION_SECRET}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -101,8 +152,13 @@ services:
|
|||||||
- prodnet
|
- prodnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
sqlserverdata-prod:
|
# Declared external so `docker compose down -v` leaves the data alone: Compose
|
||||||
driver: local
|
# removes only the volumes it owns. Create them once with `docker volume
|
||||||
|
# create` before the first `up`.
|
||||||
|
biergartendata-prod:
|
||||||
|
external: true
|
||||||
|
seaweedfsdata-prod:
|
||||||
|
external: true
|
||||||
nuget-cache-prod:
|
nuget-cache-prod:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
|||||||
@@ -34,38 +34,38 @@ h6,
|
|||||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
--color-base-content: oklch(28% 0.025 58); /* dark brown ink */
|
||||||
|
|
||||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
--color-primary: oklch(47.5% 0.085 62); /* mellow amber */
|
||||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
--color-primary-content: oklch(97% 0.02 62); /* warm near-white */
|
||||||
|
|
||||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
--color-secondary-content: oklch(96% 0.01 76); /* off-white */
|
||||||
|
|
||||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
--color-accent-content: oklch(28% 0.025 58); /* dark brown */
|
||||||
|
|
||||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment */
|
||||||
|
|
||||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
--color-info-content: oklch(97% 0.008 145); /* near-white */
|
||||||
|
|
||||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
--color-success: oklch(46.5% 0.06 122); /* soft barley gold */
|
||||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
--color-success-content: oklch(97% 0.02 122); /* warm near-white */
|
||||||
|
|
||||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
--color-warning: oklch(47.5% 0.09 56); /* toned amber */
|
||||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
--color-warning-content: oklch(97% 0.02 56); /* warm near-white */
|
||||||
|
|
||||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
--color-error: oklch(48% 0.09 22); /* restrained cherry */
|
||||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
--color-error-content: oklch(97% 0.006 15); /* near-white */
|
||||||
|
|
||||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
--color-surface-content: oklch(28% 0.025 58); /* dark brown */
|
||||||
|
|
||||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
--color-muted: oklch(42% 0.055 62); /* amber-brown */
|
||||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
--color-highlight-content: oklch(22% 0.025 55); /* dark brown */
|
||||||
|
|
||||||
--radius-selector: 0.375rem;
|
--radius-selector: 0.375rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -91,38 +91,38 @@ h6,
|
|||||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
--color-base-content: oklch(88% 0.008 75); /* warm off-white */
|
||||||
|
|
||||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
--color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */
|
||||||
|
|
||||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
--color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
|
||||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
--color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
|
||||||
|
|
||||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
--color-accent-content: oklch(18% 0.012 55); /* near-black */
|
||||||
|
|
||||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white */
|
||||||
|
|
||||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
--color-info: oklch(61.5% 0.04 145); /* cool hop green */
|
||||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
--color-info-content: oklch(23% 0.006 145); /* pale green-white */
|
||||||
|
|
||||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
--color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */
|
||||||
|
|
||||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
--color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */
|
||||||
|
|
||||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
--color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
|
||||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
--color-error-content: oklch(23% 0.004 15); /* near-white */
|
||||||
|
|
||||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
--color-surface-content: oklch(88% 0.008 75); /* warm off-white */
|
||||||
|
|
||||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
--color-muted: oklch(78% 0.018 72); /* warm grey */
|
||||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white */
|
||||||
|
|
||||||
--radius-selector: 0.375rem;
|
--radius-selector: 0.375rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -148,40 +148,38 @@ h6,
|
|||||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||||
|
|
||||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
--color-primary-content: oklch(34% 0.01 295); /* pale lavender */
|
||||||
|
|
||||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
--color-secondary-content: oklch(30% 0.006 10); /* warm near-white */
|
||||||
|
|
||||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
--color-accent-content: oklch(37.5% 0.01 130); /* near-white */
|
||||||
|
|
||||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender */
|
||||||
|
|
||||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
--color-info-content: oklch(23% 0.008 250); /* pale indigo-white */
|
||||||
|
|
||||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
--color-success-content: oklch(27.5% 0.008 145); /* pale green-white */
|
||||||
|
|
||||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
--color-warning-content: oklch(32.5% 0.03 65); /* near-white */
|
||||||
|
|
||||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
--color-error: oklch(62% 0.055 22); /* kriek red */
|
||||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
--color-error-content: oklch(22% 0.006 22); /* near-white */
|
||||||
|
|
||||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||||
|
|
||||||
--color-muted: oklch(
|
--color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */
|
||||||
77.6% 0.022 300
|
|
||||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
|
||||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */
|
||||||
|
|
||||||
--radius-selector: 0.5rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -207,38 +205,38 @@ h6,
|
|||||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
--color-base-content: oklch(20% 0.022 122); /* deep green-black */
|
||||||
|
|
||||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
--color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
|
||||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
--color-primary-content: oklch(97% 0.005 118); /* near-white */
|
||||||
|
|
||||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
--color-secondary-content: oklch(97% 0.005 128); /* near-white */
|
||||||
|
|
||||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
--color-accent-content: oklch(22% 0.022 148); /* deep green */
|
||||||
|
|
||||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
--color-neutral-content: oklch(38.4% 0.005 118); /* near-white */
|
||||||
|
|
||||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
--color-info-content: oklch(98% 0.005 232); /* near-white */
|
||||||
|
|
||||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
--color-success-content: oklch(98% 0.005 145); /* near-white */
|
||||||
|
|
||||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
--color-warning: oklch(51% 0.1 76); /* ripening grain amber */
|
||||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
|
||||||
|
|
||||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
--color-error: oklch(51.5% 0.1 18); /* dusty rose red */
|
||||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
--color-error-content: oklch(98% 0.005 15); /* near-white */
|
||||||
|
|
||||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
--color-surface-content: oklch(20% 0.022 122); /* deep green-black */
|
||||||
|
|
||||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
--color-muted: oklch(38% 0.055 120); /* sage green */
|
||||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black */
|
||||||
|
|
||||||
--radius-selector: 2rem;
|
--radius-selector: 2rem;
|
||||||
--radius-field: 2rem;
|
--radius-field: 2rem;
|
||||||
@@ -249,3 +247,33 @@ h6,
|
|||||||
--depth: 0;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */
|
||||||
|
:is(.input, .select, .textarea):not(
|
||||||
|
.input-neutral,
|
||||||
|
.input-primary,
|
||||||
|
.input-secondary,
|
||||||
|
.input-accent,
|
||||||
|
.input-info,
|
||||||
|
.input-success,
|
||||||
|
.input-warning,
|
||||||
|
.input-error,
|
||||||
|
.select-neutral,
|
||||||
|
.select-primary,
|
||||||
|
.select-secondary,
|
||||||
|
.select-accent,
|
||||||
|
.select-info,
|
||||||
|
.select-success,
|
||||||
|
.select-warning,
|
||||||
|
.select-error,
|
||||||
|
.textarea-neutral,
|
||||||
|
.textarea-primary,
|
||||||
|
.textarea-secondary,
|
||||||
|
.textarea-accent,
|
||||||
|
.textarea-info,
|
||||||
|
.textarea-success,
|
||||||
|
.textarea-warning,
|
||||||
|
.textarea-error
|
||||||
|
) {
|
||||||
|
--input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Login</p>
|
<p className="font-semibold">Login</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||||
username & password
|
username & password
|
||||||
</p>
|
</p>
|
||||||
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Register</p>
|
<p className="font-semibold">Register</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||||
user details
|
user details
|
||||||
</p>
|
</p>
|
||||||
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Session</p>
|
<p className="font-semibold">Session</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Protected Routes</p>
|
<p className="font-semibold">Protected Routes</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
This dashboard requires authentication via{' '}
|
This dashboard requires authentication via{' '}
|
||||||
<code className="kbd kbd-sm">requireAuth()</code>
|
<code className="kbd kbd-sm">requireAuth()</code>
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Token Refresh</p>
|
<p className="font-semibold">Token Refresh</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||||
header
|
header
|
||||||
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Account Management</p>
|
<p className="font-semibold">Account Management</p>
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="flex w-full items-center justify-between gap-5">
|
<div className="flex w-full items-center justify-between gap-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
<span className="text-base-content/70 mt-1">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="card-title text-lg">{title}</h2>
|
<h2 className="card-title text-lg">{title}</h2>
|
||||||
<p className="text-sm text-base-content/70">{description}</p>
|
<p className="text-sm text-base-content/70">{description}</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/70 mb-3">
|
||||||
Session Info
|
Session Info
|
||||||
</p>
|
</p>
|
||||||
<div className="stats stats-vertical w-full">
|
<div className="stats stats-vertical w-full">
|
||||||
|
|||||||
@@ -208,11 +208,11 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Settings className="size-7" aria-hidden="true" />
|
<Settings className="size-7" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight">Account Settings</h1>
|
||||||
<p className="text-base-content/70">
|
<p className="text-base-content/70">
|
||||||
Manage the account details for{' '}
|
Manage the account details for{' '}
|
||||||
<span className="font-mono">{username}</span>
|
<span className="font-mono">{username}</span>
|
||||||
@@ -257,7 +257,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
className="link link-hover text-sm text-base-content/70 inline-block"
|
||||||
>
|
>
|
||||||
← Back to dashboard
|
← Back to dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
||||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||||
<AuthFlowDemoCard />
|
<AuthFlowDemoCard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
|||||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
<span className="text-base-content/70 text-xs uppercase tracking-widest font-semibold">
|
||||||
Confirmed at
|
Confirmed at
|
||||||
</span>
|
</span>
|
||||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const LoginCallout = () => (
|
|||||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
<Link to="/" className="link link-hover text-sm text-base-content/70">
|
||||||
Back to home
|
Back to home
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const RegisterCallout = () => (
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
className="link link-hover text-sm text-base-content/70 inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||||
Back to home
|
Back to home
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ const BeerListCard = ({ beers }: BeerListCardProps) => (
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
<span className="font-bold text-sm">{beer.name}</span>
|
<span className="font-bold text-sm">{beer.name}</span>
|
||||||
<span className="text-xs text-base-content/60">{beer.description}</span>
|
<span className="text-xs text-base-content/70">{beer.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
||||||
{beer.style}
|
{beer.style}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-base-content/60 tabular-nums whitespace-nowrap">
|
<span className="text-xs text-base-content/70 tabular-nums whitespace-nowrap">
|
||||||
{beer.abv.toFixed(1)}% ABV
|
{beer.abv.toFixed(1)}% ABV
|
||||||
</span>
|
</span>
|
||||||
<StarRating value={beer.rating} size="xs" />
|
<StarRating value={beer.rating} size="xs" />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => {
|
|||||||
<div key={style} className="flex flex-col gap-1">
|
<div key={style} className="flex flex-col gap-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="text-sm font-semibold">{style}</span>
|
<span className="text-sm font-semibold">{style}</span>
|
||||||
<span className="text-xs text-base-content/60 tabular-nums">{count}</span>
|
<span className="text-xs text-base-content/70 tabular-nums">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
<progress
|
<progress
|
||||||
className="progress progress-primary h-1.5"
|
className="progress progress-primary h-1.5"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface BreweryDetailsCardProps {
|
|||||||
|
|
||||||
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="text-base-content/60">{label}</span>
|
<span className="text-base-content/70">{label}</span>
|
||||||
<span className="font-semibold">{value}</span>
|
<span className="font-semibold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ const BreweryHeaderCard = ({
|
|||||||
<div className="card-body gap-4 p-7">
|
<div className="card-body gap-4 p-7">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-base-content/50">
|
<span className="text-xs font-bold uppercase tracking-widest text-base-content/70">
|
||||||
Brewery
|
Brewery
|
||||||
</span>
|
</span>
|
||||||
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||||
Est. {foundedYear}
|
Est. {foundedYear}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-4xl leading-tight m-0">{brewery.breweryName}</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight m-0">{brewery.breweryName}</h1>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/60 flex items-center gap-1 m-0">
|
<p className="text-sm text-base-content/70 flex items-center gap-1 m-0">
|
||||||
<MapPin className="size-4" aria-hidden="true" />
|
<MapPin className="size-4" aria-hidden="true" />
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
@@ -57,7 +57,7 @@ const BreweryHeaderCard = ({
|
|||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<StarRating value={avgRating} size="sm" />
|
<StarRating value={avgRating} size="sm" />
|
||||||
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
||||||
<span className="text-sm text-base-content/60">({ratingsCount} ratings)</span>
|
<span className="text-sm text-base-content/70">({ratingsCount} ratings)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface CommunityStatsCardProps {
|
|||||||
|
|
||||||
const StatRow = ({ label, value }: { label: string; value: number }) => (
|
const StatRow = ({ label, value }: { label: string; value: number }) => (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="text-base-content/60">{label}</span>
|
<span className="text-base-content/70">{label}</span>
|
||||||
<span className="font-semibold tabular-nums">{value}</span>
|
<span className="font-semibold tabular-nums">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => (
|
|||||||
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<h3 className="text-xl m-0">Your rating</h3>
|
<h3 className="text-xl m-0">Your rating</h3>
|
||||||
<p className="text-sm text-base-content/60 m-0">
|
<p className="text-sm text-base-content/70 m-0">
|
||||||
{value
|
{value
|
||||||
? `You rated this ${value} of 5. Tap the same star to clear.`
|
? `You rated this ${value} of 5. Tap the same star to clear.`
|
||||||
: 'Tap a star to rate this brewery.'}
|
: 'Tap a star to rate this brewery.'}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
|||||||
<span className="font-bold text-sm">{comment.user}</span>
|
<span className="font-bold text-sm">{comment.user}</span>
|
||||||
)}
|
)}
|
||||||
<StarRating value={comment.rating} size="xs" />
|
<StarRating value={comment.rating} size="xs" />
|
||||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
<span className="text-xs text-base-content/70">{comment.time}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleLike(comment.id)}
|
onClick={() => onToggleLike(comment.id)}
|
||||||
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
||||||
comment.liked ? 'text-primary' : 'text-base-content/60'
|
comment.liked ? 'text-primary' : 'text-base-content/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{comment.liked ? (
|
{comment.liked ? (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/50 mt-2">
|
<p className="text-sm text-base-content/70 mt-2">
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps)
|
|||||||
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
||||||
<div className="flex flex-col gap-3.5">
|
<div className="flex flex-col gap-3.5">
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm font-semibold text-base-content/60">
|
<div className="text-sm font-semibold text-base-content/70">
|
||||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
||||||
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/60 m-0">
|
<p className="text-sm text-base-content/70 m-0">
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
|
|||||||
<div className="card-body gap-1.5">
|
<div className="card-body gap-1.5">
|
||||||
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm text-base-content/60">
|
<div className="text-sm text-base-content/70">
|
||||||
{brewery.location.cityName},{' '}
|
{brewery.location.cityName},{' '}
|
||||||
{brewery.location.stateProvinceCode}
|
{brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const NearbyBreweriesHeader = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
||||||
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
||||||
<span className="text-sm text-base-content/60">
|
<span className="text-sm text-base-content/70">
|
||||||
{center
|
{center
|
||||||
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||||
: 'Finding breweries near you…'}
|
: 'Finding breweries near you…'}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const NearbyBreweryList = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withDistance.length === 0 && center && (
|
{withDistance.length === 0 && center && (
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/70">
|
||||||
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const NearbyBreweryListItem = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm text-base-content/60">
|
<div className="text-sm text-base-content/70">
|
||||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface SidebarCardProps {
|
|||||||
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body gap-3 p-6">
|
<div className="card-body gap-3 p-6">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
|
<p className="text-xs font-bold uppercase tracking-widest text-base-content/70 m-0">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||||
<div className="max-w-7xl mx-auto px-5 pt-10">
|
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||||
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight mb-2">Breweries</h1>
|
||||||
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
<p className="text-lg text-base-content/70 max-w-xl m-0">
|
||||||
Discover our partner breweries — start with this week's feature, then see
|
Discover our partner breweries — start with this week's feature, then see
|
||||||
what's pouring near you.
|
what's pouring near you.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -80,15 +80,15 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="max-w-4xl mx-auto px-6 pt-8 pb-16">
|
<div className="mx-auto max-w-7xl px-5 pt-10 pb-16">
|
||||||
<Link
|
<Link
|
||||||
to="/breweries"
|
to="/breweries"
|
||||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to breweries
|
← Back to breweries
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_23rem] gap-5 items-start">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<BreweryHeaderCard
|
<BreweryHeaderCard
|
||||||
brewery={brewery}
|
brewery={brewery}
|
||||||
|
|||||||
@@ -72,14 +72,14 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-14">
|
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||||
<Link
|
<Link
|
||||||
to="/breweries"
|
to="/breweries"
|
||||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to Breweries
|
← Back to Breweries
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Full brewery directory</h1>
|
||||||
<p className="text-base-content/70 mb-6">
|
<p className="text-base-content/70 mb-6">
|
||||||
All of our partner breweries, newest first.
|
All of our partner breweries, newest first.
|
||||||
</p>
|
</p>
|
||||||
@@ -93,7 +93,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
{allBreweries.length === 0 ? (
|
{allBreweries.length === 0 ? (
|
||||||
<p className="text-center text-base-content/60 mt-12">
|
<p className="text-center text-base-content/70 mt-12">
|
||||||
No breweries found yet.
|
No breweries found yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Clock, Lock, Tools } from 'iconoir-react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
type Reason = 'soon' | 'maintenance' | 'auth';
|
||||||
|
|
||||||
|
type Action = { to: string; label: string; className: string };
|
||||||
|
|
||||||
|
type Content = {
|
||||||
|
Icon: ComponentType<{ className?: string }>;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
actions: Action[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTENT: Record<Reason, Content> = {
|
||||||
|
soon: {
|
||||||
|
Icon: Clock,
|
||||||
|
title: 'This feature is not available yet',
|
||||||
|
description: 'This feature is still in development.',
|
||||||
|
actions: [
|
||||||
|
{ to: '/breweries', label: 'Browse breweries', className: 'btn-primary' },
|
||||||
|
{ to: '/', label: 'Back to home', className: 'btn-ghost' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
Icon: Tools,
|
||||||
|
title: 'This feature is temporarily unavailable',
|
||||||
|
description: 'This feature is offline for maintenance. Try again in a few minutes.',
|
||||||
|
actions: [],
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
Icon: Lock,
|
||||||
|
title: 'Sign in to use this feature',
|
||||||
|
description: 'This feature requires a valid session. Log in or create an account to continue.',
|
||||||
|
actions: [
|
||||||
|
{ to: '/login', label: 'Login', className: 'btn-primary' },
|
||||||
|
{ to: '/register', label: 'Register', className: 'btn-outline' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeatureNotAvailableProps = {
|
||||||
|
reason?: Reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
|
||||||
|
const { Icon, title, description, actions } = CONTENT[reason];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col items-center gap-3 rounded-box border border-base-300 bg-base-100 px-6 py-12 text-center">
|
||||||
|
<div className="flex size-14 items-center justify-center rounded-full bg-base-200 text-base-content/70">
|
||||||
|
<Icon className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex max-w-md flex-col gap-2">
|
||||||
|
<h2 className="font-serif text-2xl font-bold leading-tight">{title}</h2>
|
||||||
|
<p className="text-base-content/70 text-pretty">{description}</p>
|
||||||
|
</div>
|
||||||
|
{actions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
||||||
|
{actions.map(({ to, label, className }) => (
|
||||||
|
<Link key={to} to={to} className={`btn btn-sm ${className}`}>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureNotAvailable;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
||||||
import type { Route } from './+types/beer-styles';
|
import type { Route } from './+types/beer-styles';
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
|||||||
const BeerStyles = () => {
|
const BeerStyles = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beer Styles</h1>
|
||||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<FeatureNotAvailable />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
||||||
import type { Route } from './+types/beers';
|
import type { Route } from './+types/beers';
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
|||||||
const Beers = () => {
|
const Beers = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beers</h1>
|
||||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||||
|
<div className="mt-8">
|
||||||
|
<FeatureNotAvailable />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, userEvent, waitFor, within } from 'storybook/test';
|
||||||
|
import AiDisclosureDialog from './AiDisclosureDialog';
|
||||||
|
|
||||||
|
const aiDisclosureDescription = `Tells visitors that the catalogue is AI-generated fixture data before they engage with it, as the pipeline ethics documentation requires. It opens on every full page load and closes for the rest of that load once acknowledged.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Disclosure/AiDisclosureDialog',
|
||||||
|
component: AiDisclosureDialog,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
defaultOpen: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: aiDisclosureDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof AiDisclosureDialog>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Open: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await waitFor(async () => {
|
||||||
|
await expect(
|
||||||
|
canvas.getByRole('heading', { name: /this content is ai-generated/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
canvas.getByRole('link', { name: /how this data was generated/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Acknowledged: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /i understand/i }));
|
||||||
|
await expect(
|
||||||
|
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InitiallyDismissed: Story = {
|
||||||
|
args: {
|
||||||
|
defaultOpen: false,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.queryByRole('heading', { name: /this content is ai-generated/i }),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { Sparks } from 'iconoir-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const ethicsDocUrl =
|
||||||
|
'https://github.com/aaronpo97/the-biergarten-app/blob/main/docs/pipeline/ETHICS-AND-KNOWN-ISSUES.md';
|
||||||
|
|
||||||
|
const AiDisclosureDialog = ({ defaultOpen = true }: { defaultOpen?: boolean }) => {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal modal-open"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="ai-disclosure-title"
|
||||||
|
>
|
||||||
|
<div className="modal-box max-w-md">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<span className="text-primary">
|
||||||
|
<Sparks className="size-10" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<h2 id="ai-disclosure-title" className="text-lg font-bold">
|
||||||
|
This content is AI-generated
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
The Biergarten App is a proof of concept. Every brewery, beer, and user in
|
||||||
|
this catalogue is fixture data produced by a language model — none of it
|
||||||
|
describes a real business, person, or beer.
|
||||||
|
</p>
|
||||||
|
<div role="alert" className="alert alert-warning alert-soft text-left">
|
||||||
|
<span className="text-sm">
|
||||||
|
Brewing details, local-language text, and coordinates are known to
|
||||||
|
contain fabrications. Treat nothing here as factual.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={ethicsDocUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="link link-primary text-sm"
|
||||||
|
>
|
||||||
|
How this data was generated
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary w-full"
|
||||||
|
autoFocus
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
I understand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiDisclosureDialog;
|
||||||
@@ -21,10 +21,9 @@ export const meta = ({}: Route.MetaArgs) => [
|
|||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
|
|
||||||
// The rest of the landing page stands on its own, so a brewery service
|
|
||||||
// outage degrades to the empty state instead of an error page.
|
|
||||||
let recentBreweries: Brewery[] = [];
|
let recentBreweries: Brewery[] = [];
|
||||||
|
|
||||||
|
const randomOffset = Math.random() * 1.0
|
||||||
try {
|
try {
|
||||||
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
|
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const TextTokenSample = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
||||||
@@ -54,7 +54,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
Core
|
Core
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
@@ -69,7 +69,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
Status
|
Status
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||||
@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||||
Content
|
Content
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const EmptyState = ({ message }: { message: string }) => (
|
const EmptyState = ({ message }: { message: string }) => (
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body items-center py-12">
|
<div className="card-body items-center py-12">
|
||||||
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
|
<p className="text-sm text-base-content/70 text-center m-0">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
|
|||||||
<div className="card-body gap-4 p-6">
|
<div className="card-body gap-4 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
||||||
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
|
<p className="text-sm text-base-content/70 m-0 mt-0.5">@{username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwnProfile ? (
|
{isOwnProfile ? (
|
||||||
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
|
|||||||
|
|
||||||
<p className="text-sm leading-normal m-0">{bio}</p>
|
<p className="text-sm leading-normal m-0">{bio}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
|
<div className="flex flex-col gap-1.5 text-sm text-base-content/70">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||||
{location}
|
{location}
|
||||||
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="flex items-center justify-between gap-2">
|
<div key={stat.label} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm text-base-content/60">{stat.label}</span>
|
<span className="text-sm text-base-content/70">{stat.label}</span>
|
||||||
<span className="font-serif text-lg font-bold tabular-nums">
|
<span className="font-serif text-lg font-bold tabular-nums">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
|||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/70 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.comment && (
|
{item.comment && (
|
||||||
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
|
<p className="text-sm text-base-content/70 italic mt-1.5 mb-0">
|
||||||
“{item.comment}”
|
“{item.comment}”
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
<p className="text-xs text-base-content/70 mt-1.5 mb-0">
|
||||||
{item.time}
|
{item.time}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
|
<p className="text-xs text-base-content/70 m-0 truncate">{f.meta}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const LikedTab = ({ liked }: LikedTabProps) => {
|
|||||||
<div className="h-[120px] bg-base-300" />
|
<div className="h-[120px] bg-base-300" />
|
||||||
<div className="card-body p-4 gap-1">
|
<div className="card-body p-4 gap-1">
|
||||||
<p className="font-serif font-bold m-0">{item.name}</p>
|
<p className="font-serif font-bold m-0">{item.name}</p>
|
||||||
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
|
<p className="text-sm text-base-content/70 m-0">{item.meta}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StarRating value={item.rating} size="xs" />
|
<StarRating value={item.rating} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200 pb-16">
|
<div className="min-h-screen bg-base-200 pb-16">
|
||||||
<div className="max-w-[1080px] mx-auto px-6 pt-8">
|
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||||
<div className="rounded-box h-[190px] bg-base-300" />
|
<div className="rounded-box h-[190px] bg-base-300" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import './app.css';
|
|||||||
import Navbar from './components/ui/navbar/Navbar';
|
import Navbar from './components/ui/navbar/Navbar';
|
||||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||||
import { getOptionalAuth } from './features/auth/auth.server';
|
import { getOptionalAuth } from './features/auth/auth.server';
|
||||||
|
import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
|
||||||
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
|
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
@@ -60,6 +61,7 @@ const App = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
<>
|
<>
|
||||||
<Navbar auth={auth} />
|
<Navbar auth={auth} />
|
||||||
<ToastProvider />
|
<ToastProvider />
|
||||||
|
<AiDisclosureDialog />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -84,7 +86,7 @@ export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="pt-16 p-4 container mx-auto">
|
<main className="mx-auto max-w-7xl px-5 pt-10">
|
||||||
<h1>{message}</h1>
|
<h1>{message}</h1>
|
||||||
<p>{details}</p>
|
<p>{details}</p>
|
||||||
{stack && (
|
{stack && (
|
||||||
|
|||||||
30
web/frontend/package-lock.json
generated
30
web/frontend/package-lock.json
generated
@@ -3948,9 +3948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5103,9 +5103,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.21",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6775,9 +6775,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7655,9 +7655,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8972,9 +8972,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.1",
|
"es-define-property": "^1.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user