mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Compare commits
10 Commits
30ad304a11
...
0bd115eccb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bd115eccb | ||
|
|
b41ae73e93 | ||
|
|
43eb3a1ddb | ||
|
|
fb0b1748aa | ||
|
|
071b5fcc3c | ||
|
|
9afab64f67 | ||
|
|
dfe248ea5f | ||
|
|
ff775e3b8a | ||
|
|
0e51630439 | ||
|
|
bd0e9e4a20 |
101
.github/pull_request_template.md
vendored
Normal file
101
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
## Summary
|
||||
|
||||
<!--
|
||||
State WHAT is changing and WHY, in two to five sentences.
|
||||
A reader should understand the change without opening the diff.
|
||||
Describe the problem first, then the outcome.
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Tick the one option that best describes this PR. -->
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor (no change in behaviour)
|
||||
- [ ] Performance improvement
|
||||
- [ ] Documentation
|
||||
- [ ] Tests only
|
||||
- [ ] Build, CI or dependency update
|
||||
- [ ] Other (please specify):
|
||||
|
||||
## Related issues
|
||||
|
||||
<!--
|
||||
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
|
||||
|
||||
Fixes #123
|
||||
Fixes #124, fixes #125
|
||||
Fixes owner/other-repo#456
|
||||
|
||||
Closing keywords only take effect when the PR targets the repository's default branch.
|
||||
Use "Related to #123" if the PR should not close the issue.
|
||||
If there is no issue, write "N/A" and explain the motivation in the Summary.
|
||||
-->
|
||||
|
||||
## Approach and context
|
||||
|
||||
<!--
|
||||
Explain how the change works and why this approach was chosen.
|
||||
Include, where relevant:
|
||||
- The decisions you made that are not obvious from the code.
|
||||
- Alternatives you considered and why you rejected them.
|
||||
- Known limitations or shortcomings, and any planned follow-up work.
|
||||
- Background such as benchmark results or design documents.
|
||||
|
||||
Summarise the key points of any external link. Links may be inaccessible to future readers.
|
||||
-->
|
||||
|
||||
## How this was tested
|
||||
|
||||
<!--
|
||||
Describe what you ran and what you observed. Be specific, and state only what you actually did.
|
||||
For example: the automated tests added or changed, manual steps and their outcome,
|
||||
environments or platforms covered, and anything you were unable to test.
|
||||
For a bug fix, note the test that fails without the fix.
|
||||
-->
|
||||
|
||||
## Compatibility and risk
|
||||
|
||||
- [ ] This change is backwards compatible.
|
||||
- [ ] This change is **breaking** (complete the details below).
|
||||
- [ ] This change deprecates existing behaviour (complete the details below).
|
||||
|
||||
<!--
|
||||
If breaking or deprecating, describe: what changes, who is affected,
|
||||
and the migration path or recommended alternative.
|
||||
Also note any deployment, configuration, data migration or rollback considerations.
|
||||
-->
|
||||
|
||||
## User-facing change
|
||||
|
||||
<!--
|
||||
Does this PR change behaviour that users, operators or API consumers will notice?
|
||||
If not, write "None".
|
||||
If so, write one or two sentences suitable for release notes, in plain language and from the
|
||||
user's point of view. If users must take action when upgrading, state clearly that action is required.
|
||||
-->
|
||||
|
||||
```release-note
|
||||
|
||||
```
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
<!--
|
||||
Help reviewers focus. For example:
|
||||
- Where to start, or a suggested order in which to read the files.
|
||||
- Areas where you would particularly value scrutiny, or where you are uncertain.
|
||||
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
|
||||
or a code generator), so that reviewers can reproduce the steps.
|
||||
- Screenshots or recordings for visual changes (include before and after where useful).
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
|
||||
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
|
||||
- [ ] I have added or updated tests that cover this change.
|
||||
- [ ] The build succeeds and the tests pass locally.
|
||||
- [ ] The code follows the existing patterns and style of this repository.
|
||||
- [ ] I have updated documentation, changelogs or API references where required.
|
||||
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.
|
||||
@@ -565,11 +565,12 @@ are not seeded either.
|
||||
|
||||
**Environments**:
|
||||
|
||||
| Environment | Compose file |
|
||||
| ----------- | -------------------------- |
|
||||
| Development | `docker-compose.dev.yaml` |
|
||||
| Testing | `docker-compose.test.yaml` |
|
||||
| Production | `docker-compose.prod.yaml` |
|
||||
| Environment | Compose file |
|
||||
| --------------- | ------------------------------- |
|
||||
| Development | `docker-compose.dev.yaml` |
|
||||
| Testing | `docker-compose.test.yaml` |
|
||||
| Production | `docker-compose.prod.yaml` |
|
||||
| Production seed | `docker-compose.prod.seed.yaml` |
|
||||
|
||||
For details, see [Docker Guide](docker.md).
|
||||
|
||||
|
||||
@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository.GetByIdAsync`'s left join |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
|
||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
|
||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
||||
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
|
||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` |
|
||||
|
||||
### Tables defined in the schema but not yet wired to a feature slice
|
||||
|
||||
|
||||
@@ -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). |
|
||||
| **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, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
|
||||
| **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. |
|
||||
| `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. |
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
sequenceDiagram
|
||||
box Production Environment (docker-compose.prod.yaml)
|
||||
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||
participant ProdMig as Migrations (run-once)
|
||||
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
|
||||
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
|
||||
end
|
||||
|
||||
ProdMig-->>ProdDB: depends_on
|
||||
ProdMig->>ProdDB: Run migrations
|
||||
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
|
||||
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
|
||||
ProdWeb-->>ProdAPI: depends_on: api.core healthy
|
||||
ProdAPI->>ProdDB: Connect and serve
|
||||
ProdAPI->>ProdS3: Store and read photos
|
||||
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack
|
||||
|
||||
@@ -134,27 +134,136 @@ containers, which would end the run prematurely. See
|
||||
- Production logging levels
|
||||
- No database clearing
|
||||
- Optimized build configurations
|
||||
- Health checks enabled
|
||||
- Restart policies (unless-stopped)
|
||||
- Health checks on every long-running service
|
||||
- Restart policies that survive a reboot (see
|
||||
[Restart tolerance](#restart-tolerance))
|
||||
- Security hardening
|
||||
- Only `frontend` publishes a port to the host; `sqlserver` and `api.core` are
|
||||
reachable only from other containers on `prodnet`
|
||||
- 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` (named `biergarten-prodnet`
|
||||
so the seed job can join it from its own Compose project)
|
||||
- Seeding is a separate, explicitly run compose file
|
||||
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
|
||||
catalogue as it is. See [Production data](#production-data) below.
|
||||
|
||||
**Services**:
|
||||
|
||||
```yaml
|
||||
sqlserver # Production SQL Server (internal only)
|
||||
database.migrations # Schema updates only
|
||||
sqlserver # Production SQL Server (internal only)
|
||||
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)
|
||||
```
|
||||
|
||||
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
|
||||
restart touches neither the schema nor the catalogue. Run them explicitly at
|
||||
deploy time: see [Migrating production](#migrating-production) and
|
||||
[Seeding production](#seeding-production-docker-composeprodseedyaml).
|
||||
|
||||
**Deploy Production**:
|
||||
|
||||
```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 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.
|
||||
|
||||
#### Migrating production
|
||||
|
||||
`database.migrations` is a run-once job rather than a service in the prod stack,
|
||||
so the schema is migrated when an operator asks for it instead of on every boot.
|
||||
Build the image and run it against the network the prod stack creates:
|
||||
|
||||
```bash
|
||||
docker build -t database.migrations \
|
||||
-f web/backend/Database/Database.Migrations/Dockerfile \
|
||||
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
|
||||
web/backend
|
||||
|
||||
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
|
||||
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
|
||||
```
|
||||
|
||||
DbUp records every script it has applied, so a re-run applies only what is new.
|
||||
Run this with `sqlserver` already up and healthy, and before the seed job.
|
||||
|
||||
#### Restart tolerance
|
||||
|
||||
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
|
||||
`restart: unless-stopped`, so the stack returns on its own after a container
|
||||
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
|
||||
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
|
||||
stack, so a reboot replays neither.
|
||||
|
||||
The daemon replays restart policies without replaying the `depends_on` graph, so
|
||||
after a reboot the containers come back in arbitrary order and each one tolerates
|
||||
a dependency that arrives late. `api.core` opens its database and S3 connections
|
||||
per request rather than at startup, and `frontend` answers with its own error
|
||||
states while `api.core` is still coming up.
|
||||
|
||||
Health checks report when the stack has converged:
|
||||
|
||||
| Service | Probe |
|
||||
| --- | --- |
|
||||
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
|
||||
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
|
||||
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
|
||||
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
|
||||
|
||||
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
|
||||
finish on shutdown instead of being recovered on the next start.
|
||||
|
||||
Deploy and wait for the stack to report healthy, or check convergence after a
|
||||
reboot:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
|
||||
```
|
||||
|
||||
#### Seeding production (`docker-compose.prod.seed.yaml`)
|
||||
|
||||
`database.seed` lives in its own compose file, run on demand once the prod stack
|
||||
is up, `sqlserver` is healthy, and the schema is migrated:
|
||||
|
||||
```bash
|
||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
|
||||
run --rm --build database.seed
|
||||
```
|
||||
|
||||
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
|
||||
external `biergarten-prodnet` network, which the main prod stack creates, so it
|
||||
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
|
||||
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
|
||||
up first, because the seed project has no visibility into the other project's
|
||||
health checks, so a run started before SQL Server is accepting connections fails.
|
||||
|
||||
#### Production data
|
||||
|
||||
A production deployment seeded this way serves 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/home/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 the seed job and the dialog together: any deployment that runs
|
||||
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
|
||||
ethics documentation.
|
||||
|
||||
### 4. Database only (`docker-compose.db.yaml`)
|
||||
|
||||
**Purpose**: Run just the database for local backend development outside Docker
|
||||
@@ -215,6 +324,12 @@ graph TD
|
||||
In development, `api.core` also depends on `seaweedfs`
|
||||
(`condition: service_started`, not a health check) alongside `database.seed`.
|
||||
|
||||
Production has a shorter chain, because the migration and seed jobs run outside
|
||||
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
|
||||
`frontend` waits for `api.core` to report healthy. This ordering applies to
|
||||
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
|
||||
on a host reboot.
|
||||
|
||||
**Health Check Example** (SQL Server):
|
||||
|
||||
```yaml
|
||||
@@ -255,8 +370,10 @@ api.core:
|
||||
|
||||
**Production**:
|
||||
|
||||
- `sqlserverdata-prod` - Production database files
|
||||
- `nuget-cache-prod` - Production NuGet cache
|
||||
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||
|
||||
See [Production data volumes](#production-data-volumes).
|
||||
|
||||
### Mounted volumes
|
||||
|
||||
@@ -269,6 +386,41 @@ volumes:
|
||||
|
||||
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, so `docker volume ls`
|
||||
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
|
||||
`web_`-prefixed volumes of the dev and test stacks.
|
||||
|
||||
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
|
||||
|
||||
Each environment uses isolated bridge networks:
|
||||
|
||||
@@ -260,17 +260,25 @@ and retrieving photos via an S3-compatible object store.
|
||||
|
||||
```bash
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
|
||||
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
|
||||
SEAWEEDFS_BUCKET=my-bucket # Required, no default
|
||||
```
|
||||
|
||||
- **Implementation**: `S3FileStorageProvider` throws on construction if any of
|
||||
these four values is missing
|
||||
the four required values is missing
|
||||
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
|
||||
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
|
||||
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
|
||||
SeaweedFS requires and AWS S3 also accepts
|
||||
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
|
||||
used by `GetPresignedUrl` and reaching the API response as
|
||||
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
|
||||
this has to be the address the browser uses (`http://localhost:8333` with the
|
||||
dev compose file, which publishes the S3 API port), separate from the
|
||||
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
|
||||
same value wherever one address serves both
|
||||
|
||||
## Docker-specific variables
|
||||
|
||||
@@ -317,6 +325,7 @@ from the template in place; edit them manually if you go that route.
|
||||
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
|
||||
|
||||
## Variable reference table
|
||||
|
||||
@@ -340,6 +349,7 @@ from the template in place; edit them manually if you go that route.
|
||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
|
||||
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
|
||||
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
||||
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
||||
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
||||
@@ -414,6 +424,7 @@ SMTP_FROM_NAME=The Biergarten App
|
||||
|
||||
# SeaweedFS (S3-compatible storage)
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
|
||||
SEAWEEDFS_BUCKET=my-bucket
|
||||
|
||||
@@ -257,7 +257,8 @@ API.Specs/
|
||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||
├── Mocks/
|
||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||
│ └── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
|
||||
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
||||
```
|
||||
|
||||
|
||||
@@ -68,8 +68,12 @@ WEBSITE_BASE_URL=http://localhost:3000
|
||||
# ======================
|
||||
# SeaweedFS (S3) Configuration
|
||||
# ======================
|
||||
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
|
||||
# Credentials and default bucket for the local SeaweedFS S3-compatible storage.
|
||||
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
|
||||
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
|
||||
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
|
||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
||||
SEAWEEDFS_ACCESS_KEY_ID=admin
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret
|
||||
SEAWEEDFS_BUCKET=my-bucket
|
||||
|
||||
@@ -13,9 +13,61 @@ Scenario: Public profile exposes only allowlisted fields
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
And the response JSON should have "biography" equal ""
|
||||
And the response JSON should have "avatarUrl" equal ""
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
Scenario: Public profile exposes the uploaded avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: Public profile exposes the current avatar after it is replaced
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
And I have uploaded a new avatar, replacing the previous one
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
And the public profile response should not carry my previous avatar
|
||||
|
||||
Scenario: Fetching the avatar without authentication succeeds
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve the public profile by ID without authentication
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: A different user can read someone else's avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
And I am logged in as a different user
|
||||
When I retrieve the public profile by ID
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should carry my current avatar
|
||||
|
||||
Scenario: Listing user accounts exposes each account's avatar
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And my listed account should carry my current avatar
|
||||
|
||||
Scenario: Listing user accounts exposes each account's biography
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have set my biography to "Drinks lagers."
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And my listed account should carry my biography
|
||||
|
||||
Scenario: Public profile for a non-existent account returns not found
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
@@ -26,6 +78,7 @@ Scenario: Fetching the public profile without authentication succeeds
|
||||
Given I have registered a new account
|
||||
When I retrieve the public profile by ID without authentication
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should have "avatarUrl" equal ""
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
@@ -50,6 +103,7 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
|
||||
And I have a valid access token for my account
|
||||
When I list user accounts
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should contain "avatarUrl"
|
||||
And the response JSON should not contain "email"
|
||||
And the response JSON should not contain "dateOfBirth"
|
||||
|
||||
@@ -60,6 +114,14 @@ Scenario: The caller can fetch their own account
|
||||
Then the response has HTTP status 200
|
||||
And the public profile response should match the registered account
|
||||
|
||||
Scenario: The caller's own account omits the avatar graph
|
||||
Given I have registered a new account
|
||||
And I have a valid access token for my account
|
||||
And I have uploaded an avatar
|
||||
When I retrieve my own account
|
||||
Then the response has HTTP status 200
|
||||
And the response JSON should not contain "userAvatar"
|
||||
|
||||
Scenario: The account endpoint serves the caller named by the access token
|
||||
Given I have registered a new account
|
||||
And I am logged in as a different user
|
||||
|
||||
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
55
web/backend/API/API.Specs/Mocks/MockFileStorageProvider.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using Features.ImageUploads.Services;
|
||||
|
||||
namespace API.Specs.Mocks;
|
||||
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
|
||||
/// exercise photo-backed features without object storage.
|
||||
/// </summary>
|
||||
public class MockFileStorageProvider : IFileStorageProvider
|
||||
{
|
||||
private readonly List<string> _keys = [];
|
||||
private readonly Dictionary<string, byte[]> _objects = new();
|
||||
|
||||
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
|
||||
|
||||
/// <summary>The key of the most recent upload.</summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
|
||||
public string LastUploadedKey =>
|
||||
_keys.Count > 0
|
||||
? _keys[^1]
|
||||
: throw new InvalidOperationException("no file has been uploaded");
|
||||
|
||||
public Task UploadAsync(
|
||||
string key,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using MemoryStream buffer = new();
|
||||
content.CopyTo(buffer);
|
||||
|
||||
if (!_objects.ContainsKey(key))
|
||||
_keys.Add(key);
|
||||
|
||||
_objects[key] = buffer.ToArray();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_objects.Remove(key);
|
||||
_keys.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
|
||||
/// carrying the key in its path, so specs can assert the shape callers actually receive.
|
||||
/// </summary>
|
||||
public string GetPresignedUrl(string key, TimeSpan expiresIn)
|
||||
{
|
||||
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,25 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
AssertFieldAbsent(payloadElem, field);
|
||||
}
|
||||
|
||||
[Then("the response JSON should contain {string}")]
|
||||
public void ThenTheResponseJsonShouldContainString(string field)
|
||||
{
|
||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
JsonElement root = doc.RootElement;
|
||||
|
||||
if (root.ValueKind == JsonValueKind.Object &&
|
||||
!root.TryGetProperty(field, out _) &&
|
||||
root.TryGetProperty("payload", out JsonElement payloadElem))
|
||||
{
|
||||
AssertFieldPresent(payloadElem, field);
|
||||
return;
|
||||
}
|
||||
|
||||
AssertFieldPresent(root, field);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
|
||||
/// single-resource response.
|
||||
@@ -203,4 +222,31 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
|
||||
/// </summary>
|
||||
private static void AssertFieldPresent(JsonElement element, string field)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
element
|
||||
.TryGetProperty(field, out _)
|
||||
.Should()
|
||||
.BeTrue("Expected field '{0}' to be present in the response", field);
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
element
|
||||
.EnumerateArray()
|
||||
.Should()
|
||||
.NotBeEmpty(
|
||||
"Expected at least one item to assert field '{0}' against",
|
||||
field
|
||||
);
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
AssertFieldPresent(item, field);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using API.Specs.Mocks;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Commands.Profile.UploadAvatar;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
@@ -10,6 +17,11 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
private const string AccessTokenKey = "accessToken";
|
||||
private const string RegisteredUserIdKey = "registeredUserId";
|
||||
private const string RegisteredUsernameKey = "registeredUsername";
|
||||
private const string CurrentAvatarKeyKey = "currentAvatarKey";
|
||||
private const string PreviousAvatarKeyKey = "previousAvatarKey";
|
||||
private const string BiographyKey = "biography";
|
||||
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
|
||||
private async Task SendAsync(HttpRequestMessage requestMessage)
|
||||
{
|
||||
@@ -36,22 +48,193 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
|
||||
/// 1 KiB minimum.
|
||||
/// </summary>
|
||||
private static IFormFile CreateAvatarFile()
|
||||
{
|
||||
byte[] content = new byte[2048];
|
||||
PngSignature.CopyTo(content, 0);
|
||||
MemoryStream stream = new(content);
|
||||
|
||||
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
|
||||
{
|
||||
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads an avatar and records the object key the storage provider actually received, rather
|
||||
/// than re-deriving the key template the handler uses.
|
||||
/// </summary>
|
||||
private async Task UploadAvatarAsync()
|
||||
{
|
||||
Guid userId = GetRegisteredUserId();
|
||||
|
||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
|
||||
|
||||
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
|
||||
Scenario[PreviousAvatarKeyKey] = existing;
|
||||
|
||||
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
|
||||
}
|
||||
|
||||
private MockFileStorageProvider GetStorageProvider()
|
||||
{
|
||||
return (MockFileStorageProvider)
|
||||
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
|
||||
}
|
||||
|
||||
private string GetCurrentAvatarKey()
|
||||
{
|
||||
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
|
||||
? key
|
||||
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
|
||||
}
|
||||
|
||||
private string GetPreviousAvatarKey()
|
||||
{
|
||||
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
|
||||
? key
|
||||
: throw new InvalidOperationException(
|
||||
"only one avatar has been uploaded in this scenario"
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
|
||||
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
|
||||
/// storage key would resolve against the page's own origin, so the shape is the contract here,
|
||||
/// not just the value.
|
||||
/// </summary>
|
||||
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
|
||||
{
|
||||
avatarUrl.Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
|
||||
.Should()
|
||||
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
|
||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
||||
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
|
||||
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetAvatarUrlFromResponse()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
|
||||
}
|
||||
|
||||
[Given("I have uploaded an avatar")]
|
||||
public async Task GivenIHaveUploadedAnAvatar()
|
||||
{
|
||||
await UploadAvatarAsync();
|
||||
}
|
||||
|
||||
[Given("I have uploaded a new avatar, replacing the previous one")]
|
||||
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
|
||||
{
|
||||
await UploadAvatarAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the biography straight through the repository: registration creates the profile row
|
||||
/// with an empty biography, and no endpoint edits it yet.
|
||||
/// </summary>
|
||||
[Given("I have set my biography to {string}")]
|
||||
public async Task GivenIHaveSetMyBiographyTo(string biography)
|
||||
{
|
||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
||||
IUserProfileRepository profiles =
|
||||
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
|
||||
|
||||
await profiles.UpdateBiographyAsync(
|
||||
GetRegisteredUserId(),
|
||||
biography,
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
Scenario[BiographyKey] = biography;
|
||||
}
|
||||
|
||||
[Then("the public profile response should carry my current avatar")]
|
||||
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
|
||||
{
|
||||
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
||||
}
|
||||
|
||||
[Then("the public profile response should not carry my previous avatar")]
|
||||
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
|
||||
{
|
||||
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
||||
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
|
||||
}
|
||||
|
||||
[Then("my listed account should carry my current avatar")]
|
||||
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
|
||||
{
|
||||
AssertFetchableUrl(
|
||||
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
|
||||
GetCurrentAvatarKey()
|
||||
);
|
||||
}
|
||||
|
||||
[Then("my listed account should carry my biography")]
|
||||
public void ThenMyListedAccountShouldCarryMyBiography()
|
||||
{
|
||||
string expected =
|
||||
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
|
||||
? biography
|
||||
: throw new InvalidOperationException("no biography was set in this scenario");
|
||||
|
||||
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
|
||||
}
|
||||
|
||||
/// <summary>Finds the registered account in a listing response.</summary>
|
||||
private JsonElement GetMyListedAccount()
|
||||
{
|
||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
||||
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
|
||||
|
||||
return doc
|
||||
.RootElement.EnumerateArray()
|
||||
.Should()
|
||||
.ContainSingle(item =>
|
||||
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
|
||||
)
|
||||
.Subject.Clone();
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileById()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||
await SendAsync(
|
||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by ID without authentication")]
|
||||
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
|
||||
{
|
||||
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||
await SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve the public profile by a non-existent ID")]
|
||||
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
|
||||
{
|
||||
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
|
||||
await SendAsync(
|
||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
|
||||
);
|
||||
}
|
||||
|
||||
[When("I retrieve my own account without authentication")]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using API.Core;
|
||||
using API.Specs.Mocks;
|
||||
using Features.Emails.Services;
|
||||
using Features.ImageUploads.Services;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -32,6 +33,15 @@ internal class TestApiFactory : WebApplicationFactory<Program>
|
||||
services.Remove(emailDispatcherDescriptor);
|
||||
|
||||
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||
|
||||
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
|
||||
d.ServiceType == typeof(IFileStorageProvider)
|
||||
);
|
||||
|
||||
if (fileStorageDescriptor != null)
|
||||
services.Remove(fileStorageDescriptor);
|
||||
|
||||
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ public static class ConfigurationKeys
|
||||
public const string SmtpFromName = "SMTP_FROM_NAME";
|
||||
|
||||
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
||||
|
||||
/// <summary>
|
||||
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
|
||||
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
|
||||
/// the container-internal endpoint is only usable by callers that can resolve that host.
|
||||
/// </summary>
|
||||
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
|
||||
|
||||
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
||||
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
||||
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Domain.Entities;
|
||||
|
||||
public sealed class UserAccount
|
||||
@@ -20,4 +22,10 @@ public sealed class UserAccount
|
||||
// Navigation properties
|
||||
public UserCredential? UserCredential { get; set; }
|
||||
public UserProfile? UserProfile { get; set; }
|
||||
|
||||
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
|
||||
// storage key and uploader of the underlying Photo. Clients read the avatar from
|
||||
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
|
||||
[JsonIgnore]
|
||||
public UserAvatar? UserAvatar { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
{
|
||||
private readonly string _bucket;
|
||||
private readonly IAmazonS3 _client;
|
||||
private readonly IAmazonS3 _presignClient;
|
||||
private readonly Protocol _presignProtocol;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
|
||||
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
|
||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional
|
||||
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the required configuration values is not set.
|
||||
@@ -45,6 +48,31 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
|
||||
|
||||
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
|
||||
|
||||
// A presigned URL is signed over its host, so one signed against the container-internal
|
||||
// endpoint is only usable from inside the network. Sign downloads against the public
|
||||
// endpoint instead, so a browser can fetch the object.
|
||||
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
|
||||
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
|
||||
? serviceUrl
|
||||
: configuredPublicUrl;
|
||||
|
||||
_presignClient =
|
||||
publicUrl == serviceUrl
|
||||
? _client
|
||||
: new AmazonS3Client(
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
|
||||
);
|
||||
|
||||
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
|
||||
// take the scheme from the endpoint the URL is signed against.
|
||||
_presignProtocol =
|
||||
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
|
||||
&& publicUri.Scheme == Uri.UriSchemeHttp
|
||||
? Protocol.HTTP
|
||||
: Protocol.HTTPS;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -82,14 +110,18 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
||||
BucketName = _bucket,
|
||||
Key = key,
|
||||
Expires = DateTime.UtcNow.Add(expiresIn),
|
||||
Protocol = _presignProtocol,
|
||||
};
|
||||
|
||||
return _client.GetPreSignedURL(request);
|
||||
return _presignClient.GetPreSignedURL(request);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!ReferenceEquals(_presignClient, _client))
|
||||
_presignClient.Dispose();
|
||||
|
||||
_client.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Repository;
|
||||
@@ -9,27 +10,36 @@ namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetAllUsersHandlerTests
|
||||
{
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
private readonly GetAllUsersHandler _handler;
|
||||
|
||||
public GetAllUsersHandlerTests()
|
||||
{
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(10, 5),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().BeEmpty();
|
||||
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
||||
{
|
||||
Mock<IUserListRepository> repoMock = new();
|
||||
GetAllUsersHandler handler = new(repoMock.Object);
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
@@ -38,13 +48,60 @@ public class GetAllUsersHandlerTests
|
||||
LastName = "One",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
|
||||
result
|
||||
.Should()
|
||||
.ContainSingle()
|
||||
.Which.Should()
|
||||
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MapsEachUsersOwnAvatarUrl()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount withAvatar = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "has-avatar",
|
||||
FirstName = "Has",
|
||||
LastName = "Avatar",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
UserAccount withoutAvatar = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "no-avatar",
|
||||
FirstName = "No",
|
||||
LastName = "Avatar",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
|
||||
|
||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
||||
new GetAllUsersQuery(null, null),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Select(dto => dto.AvatarUrl)
|
||||
.Should()
|
||||
.Equal($"https://storage.test/{avatarKey}?signed", string.Empty);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Repository;
|
||||
@@ -12,15 +13,21 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
{
|
||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
||||
|
||||
public GetPublicUserProfileByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
|
||||
_storageMock
|
||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
||||
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
@@ -31,6 +38,7 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
@@ -48,13 +56,48 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
"Drinks lagers.",
|
||||
$"https://storage.test/{avatarKey}?signed",
|
||||
user.CreatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
|
||||
/// the stored value: a bare key resolves against the calling page's own origin.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
|
||||
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt()
|
||||
{
|
||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.AvatarUrl.Should().NotBe(avatarKey);
|
||||
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
|
||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
||||
uri.AbsolutePath.Should().EndWith(avatarKey);
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
||||
Times.Once
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
@@ -72,6 +115,57 @@ public class GetPublicUserProfileByIdHandlerTests
|
||||
);
|
||||
|
||||
result.Biography.Should().BeEmpty();
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.Biography.Should().Be("Drinks lagers.");
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||
UserAvatar = new UserAvatar(),
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.AvatarUrl.Should().BeEmpty();
|
||||
_storageMock.Verify(
|
||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Converts user domain entities into API response models.</summary>
|
||||
public static class UserDtoMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
|
||||
/// short enough that a leaked URL stops working.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
|
||||
|
||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
|
||||
/// <param name="user">The account to convert.</param>
|
||||
/// <param name="fileStorageProvider">
|
||||
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
|
||||
/// rather than a URL, so it has to be signed before a client can fetch it.
|
||||
/// </param>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(
|
||||
this UserAccount user,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
)
|
||||
{
|
||||
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
|
||||
|
||||
return new PublicUserProfileDto(
|
||||
UserAccountId : user.UserAccountId,
|
||||
Username : user.Username,
|
||||
FirstName : user.FirstName,
|
||||
LastName : user.LastName,
|
||||
Biography : user.UserProfile?.Biography ?? string.Empty,
|
||||
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
|
||||
? string.Empty
|
||||
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
|
||||
CreatedAt : user.CreatedAt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ public record PublicUserProfileDto(
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string Biography,
|
||||
string AvatarUrl,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using Domain.Entities;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetAllUsers;
|
||||
|
||||
public class GetAllUsersHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
public class GetAllUsersHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||
{
|
||||
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
||||
GetAllUsersQuery request,
|
||||
@@ -14,6 +17,6 @@ public class GetAllUsersHandler(IUserListRepository repository)
|
||||
)
|
||||
{
|
||||
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||
return users.Select(user => user.ToPublicProfileDto());
|
||||
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.ImageUploads.Services;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
public class GetPublicUserProfileByIdHandler(
|
||||
IUserListRepository repository,
|
||||
IFileStorageProvider fileStorageProvider
|
||||
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<PublicUserProfileDto> Handle(
|
||||
@@ -18,6 +21,6 @@ public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user.ToPublicProfileDto();
|
||||
return user.ToPublicProfileDto(fileStorageProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
|
||||
/// <c>null</c> for an account whose profile has yet to be created.
|
||||
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the
|
||||
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
|
||||
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
|
||||
/// with no avatar.
|
||||
/// </remarks>
|
||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||
{
|
||||
@@ -23,51 +26,89 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
||||
IEnumerable<UserAccount> results = await connection.QueryAsync<
|
||||
UserAccount,
|
||||
UserProfile,
|
||||
Photo,
|
||||
UserAvatar,
|
||||
UserAccount
|
||||
>(
|
||||
"""
|
||||
SELECT
|
||||
ua.UserAccountID,
|
||||
ua.Username,
|
||||
ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
|
||||
ua.DateOfBirth, ua.RowVersion,
|
||||
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
|
||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
||||
ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
|
||||
ua.RowVersion, up.UserProfileID, up.UserAccountID,
|
||||
up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
|
||||
p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
|
||||
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
||||
av.RowVersion
|
||||
FROM Auth.UserAccount ua
|
||||
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
|
||||
LEFT JOIN Social.UserProfile up
|
||||
ON ua.UserAccountID = up.UserAccountID
|
||||
LEFT JOIN Social.UserAvatar av
|
||||
ON up.UserProfileID = av.UserProfileID
|
||||
AND av.ValidTo IS NULL
|
||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
||||
WHERE ua.UserAccountID = @UserAccountId
|
||||
""",
|
||||
MapUserRow,
|
||||
new { UserAccountId = id },
|
||||
splitOn: "UserProfileID"
|
||||
splitOn: AvatarSplitOn
|
||||
);
|
||||
|
||||
return results.SingleOrDefault();
|
||||
}
|
||||
|
||||
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
|
||||
{
|
||||
user.UserProfile = profile;
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
|
||||
/// profiles, so those fields are left at their default rather than read from the database.
|
||||
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are
|
||||
/// part of a public profile. Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is
|
||||
/// consumed only as public profiles, so those fields are left at their default rather than read
|
||||
/// from the database.
|
||||
/// </remarks>
|
||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryAsync<UserAccount>(
|
||||
return await connection.QueryAsync<
|
||||
UserAccount,
|
||||
UserProfile,
|
||||
Photo,
|
||||
UserAvatar,
|
||||
UserAccount
|
||||
>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
|
||||
FROM Auth.UserAccount
|
||||
ORDER BY CreatedAt DESC
|
||||
SELECT
|
||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
||||
ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
|
||||
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
|
||||
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
|
||||
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
||||
av.RowVersion
|
||||
FROM Auth.UserAccount ua
|
||||
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
|
||||
LEFT JOIN Social.UserAvatar av
|
||||
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
|
||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
||||
ORDER BY ua.CreatedAt DESC
|
||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||
""",
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||
MapUserRow,
|
||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
|
||||
splitOn: AvatarSplitOn
|
||||
);
|
||||
}
|
||||
|
||||
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
|
||||
|
||||
private static UserAccount MapUserRow(
|
||||
UserAccount user,
|
||||
UserProfile? profile,
|
||||
Photo? photo,
|
||||
UserAvatar? avatar
|
||||
)
|
||||
{
|
||||
user.UserProfile = profile;
|
||||
avatar?.Photo = photo;
|
||||
user.UserAvatar = avatar;
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -72,6 +72,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
@@ -110,6 +111,7 @@ services:
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -84,6 +84,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
33
web/docker-compose.prod.seed.yaml
Normal file
33
web/docker-compose.prod.seed.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
|
||||
name: biergarten-prod-seed
|
||||
|
||||
services:
|
||||
database.seed:
|
||||
env_file: ".env.prod"
|
||||
image: database.seed
|
||||
container_name: prod-env-database-seed
|
||||
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_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_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
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
external: true
|
||||
@@ -6,10 +6,10 @@ services:
|
||||
container_name: prod-env-sqlserver
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_SA_PASSWORD: "${DB_PASSWORD}"
|
||||
MSSQL_PID: "Express"
|
||||
volumes:
|
||||
- sqlserverdata-prod:/var/opt/mssql
|
||||
- biergartendata-prod:/var/opt/mssql
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -20,29 +20,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 30s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
database.migrations:
|
||||
env_file: ".env.prod"
|
||||
image: database.migrations
|
||||
container_name: prod-env-database-migrations
|
||||
depends_on:
|
||||
sqlserver:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Database/Database.Migrations/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}"
|
||||
restart: "no"
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 60s
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
@@ -51,7 +30,7 @@ services:
|
||||
image: api.core
|
||||
container_name: prod-env-api-core
|
||||
depends_on:
|
||||
sqlserver:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./backend
|
||||
@@ -59,8 +38,6 @@ services:
|
||||
args:
|
||||
BUILD_CONFIGURATION: Release
|
||||
APP_UID: 1000
|
||||
# No published ports: only the frontend service is reachable from outside
|
||||
# the Docker network in production. api.core stays internal to prodnet.
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "Production"
|
||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||
@@ -73,11 +50,46 @@ services:
|
||||
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"bash",
|
||||
"-c",
|
||||
'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "',
|
||||
]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
seaweedfs:
|
||||
env_file: ".env.prod"
|
||||
image: chrislusf/seaweedfs
|
||||
container_name: prod-env-seaweedfs
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
volumes:
|
||||
- nuget-cache-prod:/root/.nuget/packages
|
||||
- seaweedfsdata-prod:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
frontend:
|
||||
env_file: ".env.prod"
|
||||
@@ -85,27 +97,34 @@ services:
|
||||
container_name: prod-env-frontend
|
||||
depends_on:
|
||||
api.core:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "127.0.0.1:5656:5656"
|
||||
environment:
|
||||
NODE_ENV: "production"
|
||||
PORT: "3000"
|
||||
PORT: "5656"
|
||||
API_BASE_URL: "http://api.core:8080"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- prodnet
|
||||
|
||||
volumes:
|
||||
sqlserverdata-prod:
|
||||
driver: local
|
||||
nuget-cache-prod:
|
||||
driver: local
|
||||
biergartendata-prod:
|
||||
external: true
|
||||
seaweedfsdata-prod:
|
||||
external: true
|
||||
|
||||
networks:
|
||||
prodnet:
|
||||
name: biergarten-prodnet
|
||||
driver: bridge
|
||||
|
||||
@@ -70,6 +70,7 @@ services:
|
||||
DB_USER: "${DB_USER}"
|
||||
DB_PASSWORD: "${DB_PASSWORD}"
|
||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||
|
||||
@@ -34,38 +34,38 @@ h6,
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--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-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
--color-primary: oklch(47.5% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white */
|
||||
|
||||
--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-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-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-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-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
--color-success: oklch(46.5% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white */
|
||||
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning: oklch(47.5% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white */
|
||||
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
--color-error: oklch(48% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white */
|
||||
|
||||
--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-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-field: 0.5rem;
|
||||
@@ -91,38 +91,38 @@ h6,
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--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-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-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
--color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
|
||||
|
||||
--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-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-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
--color-info: oklch(61.5% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(23% 0.006 145); /* pale green-white */
|
||||
|
||||
--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-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-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
--color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(23% 0.004 15); /* near-white */
|
||||
|
||||
--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-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-field: 0.5rem;
|
||||
@@ -148,40 +148,38 @@ h6,
|
||||
--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-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-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-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-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-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-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-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-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-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
--color-error: oklch(62% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(22% 0.006 22); /* near-white */
|
||||
|
||||
--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(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-muted: oklch(77.6% 0.022 300); /* desaturated lavender */
|
||||
--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-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-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--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-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
--color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white */
|
||||
|
||||
--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-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-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-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-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-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
--color-warning: oklch(51% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
|
||||
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
--color-error: oklch(51.5% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white */
|
||||
|
||||
--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-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-field: 2rem;
|
||||
@@ -249,3 +247,33 @@ h6,
|
||||
--depth: 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);
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ const Navbar = ({ auth }: NavbarProps) => (
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
<Link to="/" className="lg:text-xl text-lg font-bold">
|
||||
The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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
|
||||
username & password
|
||||
</p>
|
||||
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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
|
||||
user details
|
||||
</p>
|
||||
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<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>,{' '}
|
||||
<code className="kbd kbd-sm">/email</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="flex w-full items-center justify-between gap-5">
|
||||
<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>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
|
||||
@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
</p>
|
||||
|
||||
<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
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
|
||||
@@ -208,11 +208,13 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<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">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
@@ -257,7 +259,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
|
||||
@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<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">
|
||||
<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
|
||||
</span>
|
||||
<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">
|
||||
Sign in
|
||||
</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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const RegisterCallout = () => (
|
||||
</Link>
|
||||
<Link
|
||||
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" />
|
||||
Back to home
|
||||
|
||||
@@ -23,12 +23,12 @@ const BeerListCard = ({ beers }: BeerListCardProps) => (
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<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>
|
||||
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
||||
{beer.style}
|
||||
</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
|
||||
</span>
|
||||
<StarRating value={beer.rating} size="xs" />
|
||||
|
||||
@@ -17,7 +17,7 @@ const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => {
|
||||
<div key={style} className="flex flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<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>
|
||||
<progress
|
||||
className="progress progress-primary h-1.5"
|
||||
|
||||
@@ -8,7 +8,7 @@ interface BreweryDetailsCardProps {
|
||||
|
||||
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,16 +26,18 @@ const BreweryHeaderCard = ({
|
||||
<div className="card-body gap-4 p-7">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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
|
||||
</span>
|
||||
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||
Est. {foundedYear}
|
||||
</span>
|
||||
</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 && (
|
||||
<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" />
|
||||
{formatBreweryAddress(brewery.location)}
|
||||
</p>
|
||||
@@ -57,7 +59,7 @@ const BreweryHeaderCard = ({
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<StarRating value={avgRating} size="sm" />
|
||||
<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>
|
||||
<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 }) => (
|
||||
<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>
|
||||
</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="flex flex-col gap-0.5">
|
||||
<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
|
||||
? `You rated this ${value} of 5. Tap the same star to clear.`
|
||||
: '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>
|
||||
)}
|
||||
<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>
|
||||
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleLike(comment.id)}
|
||||
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 ? (
|
||||
|
||||
@@ -24,7 +24,7 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||
</p>
|
||||
)}
|
||||
{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)}
|
||||
</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="flex flex-col gap-3.5">
|
||||
{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}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
{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)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
|
||||
<div className="card-body gap-1.5">
|
||||
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
||||
{brewery.location && (
|
||||
<div className="text-sm text-base-content/60">
|
||||
<div className="text-sm text-base-content/70">
|
||||
{brewery.location.cityName},{' '}
|
||||
{brewery.location.stateProvinceCode}
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const NearbyBreweriesHeader = ({
|
||||
return (
|
||||
<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>
|
||||
<span className="text-sm text-base-content/60">
|
||||
<span className="text-sm text-base-content/70">
|
||||
{center
|
||||
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||
: 'Finding breweries near you…'}
|
||||
|
||||
@@ -23,7 +23,7 @@ const NearbyBreweryList = ({
|
||||
return (
|
||||
<>
|
||||
{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)}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -37,7 +37,7 @@ const NearbyBreweryListItem = ({
|
||||
</span>
|
||||
</div>
|
||||
{brewery.location && (
|
||||
<div className="text-sm text-base-content/60">
|
||||
<div className="text-sm text-base-content/70">
|
||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface SidebarCardProps {
|
||||
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<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}
|
||||
</p>
|
||||
{children}
|
||||
|
||||
@@ -44,8 +44,8 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
||||
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-2">Breweries</h1>
|
||||
<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
|
||||
what's pouring near you.
|
||||
</p>
|
||||
|
||||
@@ -80,15 +80,15 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<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
|
||||
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
|
||||
</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">
|
||||
<BreweryHeaderCard
|
||||
brewery={brewery}
|
||||
|
||||
@@ -72,14 +72,16 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
|
||||
return (
|
||||
<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
|
||||
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
|
||||
</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">
|
||||
All of our partner breweries, newest first.
|
||||
</p>
|
||||
@@ -93,7 +95,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
</ClientOnly>
|
||||
|
||||
{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.
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Clock, Lock, Tools } from 'iconoir-react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
type Reason = 'soon' | 'maintenance' | 'auth';
|
||||
|
||||
interface Action {
|
||||
to: string;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
interface 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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface 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';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
||||
const BeerStyles = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<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>
|
||||
<div className="mt-8">
|
||||
<FeatureNotAvailable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
@@ -7,9 +8,12 @@ export const meta = ({}: Route.MetaArgs) => {
|
||||
const Beers = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<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>
|
||||
<div className="mt-8">
|
||||
<FeatureNotAvailable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
55
web/frontend/app/features/home/HOME_HANDOFF.md
Normal file
55
web/frontend/app/features/home/HOME_HANDOFF.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Landing page: data requirements
|
||||
|
||||
Covers `/` (`routes/home.tsx`), the public marketing landing page. The page is
|
||||
built and live. This document lists what's real vs. filler, and what backend
|
||||
work closes each gap.
|
||||
|
||||
## Status legend
|
||||
|
||||
- ✅ **Available**: real data, wired up.
|
||||
- ⚠️ **Partial**: endpoint exists but is missing a field the design needs.
|
||||
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
||||
|
||||
## Partner breweries preview
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ------------------------------ | ------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| Name, description, city/region | ✅ | `GET /api/brewery?limit=3&offset=0` | |
|
||||
| Newest-first ordering | ✅ | `GET /api/brewery` is already sorted | |
|
||||
| "{n} beers" badge | ❌ | `FILLER_BEER_COUNTS` (`utils/filler-beer-counts.ts`) | Blocked on `Features.Beers`, which has a `.csproj` and nothing else — see `../breweries/BREWERY_HANDOFF.md`. |
|
||||
|
||||
## Hero, features, closing CTA
|
||||
|
||||
Static marketing copy and one bundled photo
|
||||
(`assets/bar-background.jpg`, carried over from the archived Next.js
|
||||
frontend). No data needs. Replace the photo when real photography exists.
|
||||
|
||||
## Theme try-out
|
||||
|
||||
✅ Fully real: renders `ThemeSegmentedControl`
|
||||
(`../theme/components/ThemeSegmentedControl.tsx`), the same `join` + radio
|
||||
control the theme guide uses. Selection sets `data-theme` on `<html>` and
|
||||
persists through the `biergarten-theme` cookie that the root loader reads.
|
||||
|
||||
## Footer
|
||||
|
||||
✅ Fully real: swaps Login for Dashboard from the session that
|
||||
`getOptionalAuth` resolves in the route loader.
|
||||
|
||||
## Loader resilience
|
||||
|
||||
`getBreweries` throws a 503 `data()` response when the API is unreachable. The
|
||||
landing page catches it and renders the "No breweries have been posted yet."
|
||||
empty state, so a brewery service outage leaves the marketing content intact.
|
||||
`/breweries` keeps the opposite behaviour — a brewery page with no breweries is
|
||||
an error, so it surfaces its `ErrorBoundary`.
|
||||
|
||||
## Summary of backend work
|
||||
|
||||
1. Stand up `Features.Beers` so the per-brewery beer count is real. This is the
|
||||
same blocker already listed in `../breweries/BREWERY_HANDOFF.md`; closing it
|
||||
there closes it here.
|
||||
|
||||
Once that lands, swap `FILLER_BEER_COUNTS` for a count carried on `BreweryDto`
|
||||
(or a dedicated beers-by-brewery query) and delete
|
||||
`utils/filler-beer-counts.ts`.
|
||||
BIN
web/frontend/app/features/home/assets/bar-background.jpg
Normal file
BIN
web/frontend/app/features/home/assets/bar-background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -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;
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import ClosingCta from './ClosingCta';
|
||||
|
||||
const closingCtaDescription = `Landing page closing call to action: centred headline with the sign-up and sign-in pair.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Home/ClosingCta',
|
||||
component: ClosingCta,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: closingCtaDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ClosingCta>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: /your next favourite brewery is on the list/i }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
|
||||
'href',
|
||||
'/register',
|
||||
);
|
||||
await expect(canvas.getByRole('link', { name: 'Sign in' })).toHaveAttribute(
|
||||
'href',
|
||||
'/login',
|
||||
);
|
||||
},
|
||||
};
|
||||
24
web/frontend/app/features/home/components/ClosingCta.tsx
Normal file
24
web/frontend/app/features/home/components/ClosingCta.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const ClosingCta = () => (
|
||||
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
|
||||
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
|
||||
Your next favourite brewery is on the list.
|
||||
</h2>
|
||||
|
||||
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">
|
||||
Create an account to save breweries and log the beers you try.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link to="/register" className="btn btn-primary">
|
||||
Create an account
|
||||
</Link>
|
||||
<Link to="/login" className="btn btn-ghost">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default ClosingCta;
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import FeatureHighlights from './FeatureHighlights';
|
||||
|
||||
const featureHighlightsDescription = `Landing page feature band: three icon-led product capabilities on a base-100 strip.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Home/FeatureHighlights',
|
||||
component: FeatureHighlights,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: featureHighlightsDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof FeatureHighlights>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: 'Everything you need to plan the next round' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
for (const title of ['Browse breweries', 'Explore the beers', 'Keep a record']) {
|
||||
await expect(canvas.getByRole('heading', { name: title })).toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Bookmark, GlassHalf, MapPin } from 'iconoir-react';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: MapPin,
|
||||
title: 'Browse breweries',
|
||||
body: 'Search the directory by name or city. Every listing shows location, hours and what is on tap.',
|
||||
},
|
||||
{
|
||||
icon: GlassHalf,
|
||||
title: 'Explore the beers',
|
||||
body: 'See each brewery’s full lineup with style, ABV and tasting notes from other members.',
|
||||
},
|
||||
{
|
||||
icon: Bookmark,
|
||||
title: 'Keep a record',
|
||||
body: 'Save the places you want to visit and check off the beers you have tried from your dashboard.',
|
||||
},
|
||||
];
|
||||
|
||||
const FeatureHighlights = () => (
|
||||
<section className="border-y border-base-300 bg-base-100">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-10 px-5 py-16">
|
||||
<h2 className="m-0 max-w-[36rem] font-serif text-3xl leading-tight">
|
||||
Everything you need to plan the next round
|
||||
</h2>
|
||||
|
||||
<div className="grid gap-8 grid-cols-[repeat(auto-fit,minmax(min(100%,16rem),1fr))]">
|
||||
{features.map(({ icon: Icon, title, body }) => (
|
||||
<div key={title} className="flex flex-col gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-field bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||
<Icon width={20} height={20} aria-hidden />
|
||||
</div>
|
||||
<h3 className="m-0 font-serif text-xl">{title}</h3>
|
||||
<p className="m-0 leading-[1.55] text-[var(--color-muted)]">{body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default FeatureHighlights;
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import HeroSection from './HeroSection';
|
||||
|
||||
const heroSectionDescription = `Landing page hero: eyebrow, headline, supporting copy, the primary sign-up pair and the bar photo.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Home/HeroSection',
|
||||
component: HeroSection,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: heroSectionDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof HeroSection>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: 'Find the breweries worth the trip.' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(canvas.getByRole('link', { name: 'Create an account' })).toHaveAttribute(
|
||||
'href',
|
||||
'/register',
|
||||
);
|
||||
await expect(canvas.getByRole('link', { name: 'Browse breweries' })).toHaveAttribute(
|
||||
'href',
|
||||
'/breweries',
|
||||
);
|
||||
},
|
||||
};
|
||||
44
web/frontend/app/features/home/components/HeroSection.tsx
Normal file
44
web/frontend/app/features/home/components/HeroSection.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router';
|
||||
import barBackground from '../assets/bar-background.jpg';
|
||||
|
||||
const HeroSection = () => (
|
||||
<section className="mx-auto max-w-7xl px-5 pt-18 pb-16">
|
||||
<div className="grid items-center gap-12 grid-cols-[repeat(auto-fit,minmax(min(100%,24rem),1fr))]">
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="m-0 text-sm font-semibold uppercase tracking-widest text-[var(--color-muted)]">
|
||||
Brewery discovery
|
||||
</p>
|
||||
|
||||
<h1 className="m-0 font-serif text-[clamp(2.5rem,5vw,4rem)] leading-[1.08] text-balance">
|
||||
Find the breweries worth the trip.
|
||||
</h1>
|
||||
|
||||
<p className="m-0 max-w-[34rem] text-lg leading-[1.55] text-[var(--color-muted)]">
|
||||
Browse our partner breweries, see what they pour, and keep track of the beers
|
||||
you have tried — all in one account.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link to="/register" className="btn btn-primary">
|
||||
Create an account
|
||||
</Link>
|
||||
<Link to="/breweries" className="btn btn-outline">
|
||||
Browse breweries
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="m-0 text-sm text-[var(--color-muted)]">
|
||||
Free to join. Must be 19 years or older.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={barBackground}
|
||||
alt="A glass of lager on a bar counter"
|
||||
className="w-full max-h-[34rem] aspect-[4/5] rounded-box bg-base-300 object-cover shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default HeroSection;
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import LandingFooter from './LandingFooter';
|
||||
|
||||
const landingFooterDescription = `Landing page footer. The last link follows the session: Login when signed out, Dashboard when signed in.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Home/LandingFooter',
|
||||
component: LandingFooter,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: landingFooterDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof LandingFooter>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SignedOut: Story = {
|
||||
args: { isAuthenticated: false },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(canvas.getByRole('link', { name: 'Login' })).toHaveAttribute('href', '/login');
|
||||
await expect(canvas.queryByRole('link', { name: 'Dashboard' })).not.toBeInTheDocument();
|
||||
await expect(canvas.getByText('Please drink responsibly.')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const SignedIn: Story = {
|
||||
args: { isAuthenticated: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(canvas.getByRole('link', { name: 'Dashboard' })).toHaveAttribute(
|
||||
'href',
|
||||
'/dashboard',
|
||||
);
|
||||
await expect(canvas.queryByRole('link', { name: 'Login' })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
42
web/frontend/app/features/home/components/LandingFooter.tsx
Normal file
42
web/frontend/app/features/home/components/LandingFooter.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const footerLinks = [
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/theme', label: 'Theme guide' },
|
||||
];
|
||||
|
||||
interface LandingFooterProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const LandingFooter = ({ isAuthenticated }: LandingFooterProps) => (
|
||||
<div className="border-t border-base-300 bg-base-100">
|
||||
<footer className="footer footer-vertical sm:footer-horizontal mx-auto max-w-7xl items-center justify-between gap-4 px-5 py-6 text-sm text-[var(--color-muted)]">
|
||||
<aside>
|
||||
<span className="font-bold text-base-content">🍺 The Biergarten App</span>
|
||||
</aside>
|
||||
|
||||
<nav className="flex flex-row flex-wrap gap-5">
|
||||
{footerLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="link link-hover hover:text-primary">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{isAuthenticated ? (
|
||||
<Link to="/dashboard" className="link link-hover hover:text-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/login" className="link link-hover hover:text-primary">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<p className="m-0">Please drink responsibly.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LandingFooter;
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import type { Brewery, BreweryLocation } from '../../breweries/breweries.server';
|
||||
import PartnerBreweries from './PartnerBreweries';
|
||||
|
||||
const partnerBreweriesDescription = `Landing page preview of the three most recently added breweries. Falls back to the empty-state line when the directory is empty or the brewery service is unreachable.`;
|
||||
|
||||
const halifaxLocation: BreweryLocation = {
|
||||
breweryPostLocationId: 'loc-1',
|
||||
breweryPostId: 'brewery-1',
|
||||
cityId: 'city-1',
|
||||
cityName: 'Halifax',
|
||||
stateProvinceName: 'Nova Scotia',
|
||||
stateProvinceCode: 'NS',
|
||||
countryName: 'Canada',
|
||||
countryCode: 'CA',
|
||||
addressLine1: '12 Lower Water Street',
|
||||
addressLine2: null,
|
||||
postalCode: 'B3J 1S3',
|
||||
coordinates: { latitude: 44.6488, longitude: -63.5752 },
|
||||
};
|
||||
|
||||
const harbourline: Brewery = {
|
||||
breweryPostId: 'brewery-1',
|
||||
postedById: 'user-1',
|
||||
breweryName: 'Harbourline Brewing',
|
||||
description: 'Crisp lagers and a rotating sour program, a short walk from the waterfront.',
|
||||
createdAt: '2024-05-01T12:00:00.000Z',
|
||||
updatedAt: null,
|
||||
location: halifaxLocation,
|
||||
};
|
||||
|
||||
const oldMill: Brewery = {
|
||||
breweryPostId: 'brewery-2',
|
||||
postedById: 'user-2',
|
||||
breweryName: 'Old Mill Ale Works',
|
||||
description: 'Cask ales and English bitters brewed in a restored 1880s grain mill.',
|
||||
createdAt: '2024-04-18T09:30:00.000Z',
|
||||
updatedAt: null,
|
||||
location: {
|
||||
...halifaxLocation,
|
||||
breweryPostId: 'brewery-2',
|
||||
cityName: 'Guelph',
|
||||
stateProvinceName: 'Ontario',
|
||||
stateProvinceCode: 'ON',
|
||||
},
|
||||
};
|
||||
|
||||
const cedarAndHop: Brewery = {
|
||||
breweryPostId: 'brewery-3',
|
||||
postedById: 'user-3',
|
||||
breweryName: 'Cedar & Hop',
|
||||
description: 'A pop-up taproom that has not added a storefront location yet.',
|
||||
createdAt: '2024-04-02T18:00:00.000Z',
|
||||
updatedAt: null,
|
||||
location: null,
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Home/PartnerBreweries',
|
||||
component: PartnerBreweries,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: partnerBreweriesDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof PartnerBreweries>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithBreweries: Story = {
|
||||
args: { breweries: [harbourline, oldMill, cedarAndHop] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: 'Discover our partner breweries' }),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('link', { name: /view all breweries/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/breweries',
|
||||
);
|
||||
|
||||
const links = canvas.getAllByRole('link', { name: 'View brewery' });
|
||||
await expect(links).toHaveLength(3);
|
||||
await expect(links[0]).toHaveAttribute('href', '/breweries/brewery-1');
|
||||
|
||||
// see HOME_HANDOFF.md — the counts are filler until the beers API exists.
|
||||
await expect(canvas.getByText('14 beers')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { breweries: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(canvas.getByText('No breweries have been posted yet.')).toBeInTheDocument();
|
||||
await expect(canvas.queryByRole('link', { name: 'View brewery' })).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Link } from 'react-router';
|
||||
import type { Brewery } from '../../breweries/breweries.server';
|
||||
import { FILLER_BEER_COUNTS } from '../utils/filler-beer-counts';
|
||||
|
||||
interface PartnerBreweriesProps {
|
||||
breweries: Brewery[];
|
||||
}
|
||||
|
||||
const PartnerBreweries = ({ breweries }: PartnerBreweriesProps) => (
|
||||
<section className="mx-auto flex max-w-7xl flex-col gap-8 px-5 py-16">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="m-0 font-serif text-3xl leading-tight">
|
||||
Discover our partner breweries
|
||||
</h2>
|
||||
<p className="m-0 text-[var(--color-muted)]">
|
||||
A few recent additions to the directory.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="font-semibold text-primary hover:text-secondary hover:underline"
|
||||
>
|
||||
View all breweries →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{breweries.length === 0 ? (
|
||||
<p className="m-0 text-[var(--color-muted)]">No breweries have been posted yet.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 grid-cols-[repeat(auto-fit,minmax(min(100%,18rem),1fr))]">
|
||||
{breweries.map((brewery, index) => (
|
||||
<div key={brewery.breweryPostId} className="card bg-base-100 shadow-md">
|
||||
<div className="card-body gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="card-title m-0 font-serif text-xl">
|
||||
{brewery.breweryName}
|
||||
</h3>
|
||||
<span className="badge badge-accent badge-sm whitespace-nowrap font-semibold">
|
||||
{FILLER_BEER_COUNTS[index % FILLER_BEER_COUNTS.length]} beers
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{brewery.location && (
|
||||
<p className="m-0 text-sm text-[var(--color-muted)]">
|
||||
{brewery.location.cityName},{' '}
|
||||
{brewery.location.stateProvinceCode}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="m-0 leading-[1.55]">{brewery.description}</p>
|
||||
|
||||
<div className="card-actions">
|
||||
<Link
|
||||
to={`/breweries/${brewery.breweryPostId}`}
|
||||
className="btn btn-ghost btn-sm"
|
||||
>
|
||||
View brewery
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
export default PartnerBreweries;
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
|
||||
import ThemeTryOut from './ThemeTryOut';
|
||||
|
||||
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
|
||||
|
||||
const ThemeTryOutHarness = () => {
|
||||
const [router] = useState(() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
id: 'root',
|
||||
path: '/',
|
||||
loader: () => ({ theme: defaultThemeName }),
|
||||
element: <ThemeTryOut />,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Home/ThemeTryOut',
|
||||
component: ThemeTryOutHarness,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: themeTryOutDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ThemeTryOutHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await expect(canvas.getByText('Set the mood')).toBeVisible();
|
||||
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
|
||||
|
||||
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
|
||||
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
},
|
||||
};
|
||||
32
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal file
32
web/frontend/app/features/home/components/ThemeTryOut.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import ThemeSegmentedControl from '../../theme/components/ThemeSegmentedControl';
|
||||
|
||||
const ThemeTryOut = () => (
|
||||
<section className="bg-neutral text-neutral-content">
|
||||
<div className="mx-auto grid max-w-7xl items-center gap-8 px-5 py-16 grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="m-0 font-serif text-3xl leading-tight">
|
||||
Pick a pour that suits you
|
||||
</h2>
|
||||
<p className="m-0 max-w-[32rem] leading-[1.55] opacity-80">
|
||||
Four themes ship with the app — a light lager, a dark roast stout, a berry
|
||||
cassis and a bright weizen. Try one; this page switches with it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
|
||||
Set the mood
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
|
||||
</div>
|
||||
<span className="badge badge-outline shrink-0">4 pours</span>
|
||||
</div>
|
||||
<ThemeSegmentedControl labels="short" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export default ThemeTryOut;
|
||||
@@ -1,55 +1,51 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getBreweries, type Brewery } from '../../breweries/breweries.server';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import ClosingCta from '../components/ClosingCta';
|
||||
import FeatureHighlights from '../components/FeatureHighlights';
|
||||
import HeroSection from '../components/HeroSection';
|
||||
import LandingFooter from '../components/LandingFooter';
|
||||
import PartnerBreweries from '../components/PartnerBreweries';
|
||||
import ThemeTryOut from '../components/ThemeTryOut';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
const PARTNER_BREWERY_COUNT = 3;
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content:
|
||||
'Browse our partner breweries, see what they pour, and keep track of the beers you have tried.',
|
||||
},
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
|
||||
// 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[] = [];
|
||||
try {
|
||||
recentBreweries = await getBreweries(PARTNER_BREWERY_COUNT, 0);
|
||||
} catch {
|
||||
recentBreweries = [];
|
||||
}
|
||||
|
||||
return { isAuthenticated: auth !== null, recentBreweries };
|
||||
};
|
||||
|
||||
const Home = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username } = loaderData;
|
||||
const { isAuthenticated, recentBreweries } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back,{' '}
|
||||
<span className="font-semibold text-primary">{username}</span>!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<main className="bg-base-200 text-base-content">
|
||||
<HeroSection />
|
||||
<FeatureHighlights />
|
||||
<PartnerBreweries breweries={recentBreweries} />
|
||||
<ThemeTryOut />
|
||||
<ClosingCta />
|
||||
<LandingFooter isAuthenticated={isAuthenticated} />
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// see HOME_HANDOFF.md — the beers API does not exist yet, so the "{n} beers"
|
||||
// badge on the partner brewery cards reads from this list by card position.
|
||||
export const FILLER_BEER_COUNTS = [14, 9, 11];
|
||||
@@ -31,7 +31,7 @@ const TextTokenSample = ({
|
||||
}) => {
|
||||
return (
|
||||
<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}
|
||||
</p>
|
||||
<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>
|
||||
<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
|
||||
</p>
|
||||
<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>
|
||||
<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
|
||||
</p>
|
||||
<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>
|
||||
<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
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useMemo } from 'react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
import ThemeSegmentedControl from './ThemeSegmentedControl';
|
||||
|
||||
const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`<html>\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`;
|
||||
|
||||
interface HarnessProps {
|
||||
labels?: 'full' | 'short';
|
||||
className?: string;
|
||||
onChange?: (theme: ThemeName) => void;
|
||||
/** Theme the root loader hands the control, standing in for the theme cookie. */
|
||||
seedTheme?: ThemeName;
|
||||
}
|
||||
|
||||
const ThemeSegmentedControlHarness = ({
|
||||
labels,
|
||||
className,
|
||||
onChange,
|
||||
seedTheme = defaultThemeName,
|
||||
}: HarnessProps) => {
|
||||
// Rebuilt per arg change so the docs controls remount the control rather
|
||||
// than leaving it seeded from the previous render.
|
||||
const router = useMemo(
|
||||
() =>
|
||||
createMemoryRouter([
|
||||
{
|
||||
id: 'root',
|
||||
path: '/',
|
||||
loader: () => ({ theme: seedTheme }),
|
||||
element: (
|
||||
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
|
||||
<ThemeSegmentedControl
|
||||
labels={labels}
|
||||
className={className}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]),
|
||||
[labels, className, onChange, seedTheme],
|
||||
);
|
||||
|
||||
return <RouterProvider router={router} />;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Themes/ThemeSegmentedControl',
|
||||
component: ThemeSegmentedControlHarness,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
labels: 'full',
|
||||
seedTheme: defaultThemeName,
|
||||
onChange: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
labels: { control: 'inline-radio', options: ['full', 'short'] },
|
||||
seedTheme: {
|
||||
control: 'select',
|
||||
options: biergartenThemes.map((theme) => theme.value),
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
usesDataRouter: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: themeSegmentedControlDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ThemeSegmentedControlHarness>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const getOptions = (canvasElement: HTMLElement) => {
|
||||
const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' });
|
||||
return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] };
|
||||
};
|
||||
|
||||
/** Full labels, as the theme guide renders it. */
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const { group, radios } = getOptions(canvasElement);
|
||||
|
||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.label)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
|
||||
const [lager] = radios;
|
||||
await expect(lager.value).toBe(defaultThemeName);
|
||||
await expect(lager.checked).toBe(true);
|
||||
},
|
||||
};
|
||||
|
||||
/** Short labels, as the landing page renders it in the narrower try-out card. */
|
||||
export const ShortLabels: Story = {
|
||||
args: {
|
||||
labels: 'short',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { group } = getOptions(canvasElement);
|
||||
|
||||
for (const theme of biergartenThemes) {
|
||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/** A returning visitor whose cookie already names a theme other than the default. */
|
||||
export const SeededFromCookie: Story = {
|
||||
args: {
|
||||
seedTheme: 'biergarten-cassis',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { radios } = getOptions(canvasElement);
|
||||
|
||||
for (const radio of radios) {
|
||||
await expect(radio.checked).toBe(radio.value === 'biergarten-cassis');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectingATheme: Story = {
|
||||
play: async ({ canvasElement, args }) => {
|
||||
const previousTheme = document.documentElement.getAttribute('data-theme');
|
||||
const { group, radios } = getOptions(canvasElement);
|
||||
|
||||
await userEvent.click(within(group).getByText('Biergarten Stout'));
|
||||
|
||||
for (const radio of radios) {
|
||||
await expect(radio.checked).toBe(radio.value === 'biergarten-stout');
|
||||
}
|
||||
|
||||
await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout');
|
||||
await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout');
|
||||
await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`);
|
||||
|
||||
// The control themes the whole document, so hand <html> back to the
|
||||
// toolbar's theme before the next story mounts.
|
||||
if (previousTheme === null) {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', previousTheme);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Single column, for a sidebar or any container too narrow for the paired grid. */
|
||||
export const SingleColumn: Story = {
|
||||
args: {
|
||||
className: 'grid grid-cols-1 gap-2',
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const { radios } = getOptions(canvasElement);
|
||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
import type { loader as rootLoader } from '../../../root';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
|
||||
// The semantic aliases resolve at :root, so the attribute has to land on <html>
|
||||
// rather than a nested element for the whole document to switch.
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
};
|
||||
|
||||
interface ThemeSegmentedControlProps {
|
||||
/** `short` renders "Lager"; `full` renders "Biergarten Lager". */
|
||||
labels?: 'full' | 'short';
|
||||
className?: string;
|
||||
onChange?: (theme: ThemeName) => void;
|
||||
}
|
||||
|
||||
const ThemeSegmentedControl = ({
|
||||
labels = 'full',
|
||||
className = 'grid grid-cols-2 gap-2',
|
||||
onChange,
|
||||
}: ThemeSegmentedControlProps) => {
|
||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(rootTheme);
|
||||
|
||||
return (
|
||||
<div className={className} role="radiogroup" aria-label="Theme selector">
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
|
||||
checked
|
||||
? 'border-primary bg-primary text-primary-content shadow-md'
|
||||
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
onChange?.(theme.value);
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
|
||||
checked ? 'border-primary-content/70' : 'border-base-100'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
theme.value === 'biergarten-lager'
|
||||
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
|
||||
: theme.value === 'biergarten-stout'
|
||||
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
|
||||
: theme.value === 'biergarten-cassis'
|
||||
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
|
||||
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-bold">
|
||||
{labels === 'short' ? theme.shortLabel : theme.label}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 block truncate text-xs ${
|
||||
checked ? 'text-primary-content/75' : 'text-base-content/60'
|
||||
}`}
|
||||
>
|
||||
{theme.shortVibe}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSegmentedControl;
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouteLoaderData } from 'react-router';
|
||||
import type { loader as rootLoader } from '../../../root';
|
||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
||||
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.cookie = `${themeCookieName}=${theme}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
};
|
||||
import { biergartenThemes, defaultThemeName, type ThemeName } from '../themes';
|
||||
import ThemeSegmentedControl from './ThemeSegmentedControl';
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||
@@ -39,35 +35,7 @@ const ThemeSwitcher = () => {
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ThemeSegmentedControl onChange={setSelectedTheme} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,10 @@ export type ThemeName =
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
/** Used where the segmented control is narrow, such as the landing page. */
|
||||
shortLabel: string;
|
||||
/** A compact mood descriptor for theme pickers. */
|
||||
shortVibe: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
@@ -14,21 +18,29 @@ export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
shortLabel: 'Lager',
|
||||
shortVibe: 'Muted parchment',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
shortLabel: 'Stout',
|
||||
shortVibe: 'Charred barrel',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
shortLabel: 'Cassis',
|
||||
shortVibe: 'Blackberry barrel',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
shortLabel: 'Weizen',
|
||||
shortVibe: 'Bright spring',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,21 +6,21 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
||||
## Status legend
|
||||
|
||||
- ✅ **Available**: real data, wired up.
|
||||
- ⚠️ **Partial**: schema/repository exists but no public read endpoint.
|
||||
- ⚠️ **Partial**: reads real data, but a related gap remains (see the Gap column).
|
||||
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
||||
|
||||
## Header & sidebar
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
||||
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
||||
| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
|
||||
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
||||
| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. |
|
||||
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
|
||||
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
|
||||
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
|
||||
| Field | Status | Source | Gap |
|
||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
||||
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
||||
| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
|
||||
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
||||
| Avatar photo | ⚠️ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads the current `Social.UserAvatar` photo via the left joins in `UserListRepository.GetByIdAsync` and signs it into `avatarUrl`; an account with no avatar returns an empty string and `ProfileAvatar` shows initials. `UploadAvatarCommand` has no HTTP route yet, so only accounts created by `Database.Seed` have one — see (4) below. |
|
||||
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
|
||||
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
|
||||
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
|
||||
|
||||
## Follow action
|
||||
|
||||
@@ -36,7 +36,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
||||
| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. |
|
||||
| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. |
|
||||
|
||||
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed.
|
||||
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (3) below closes and comments/follows are loader-backed.
|
||||
|
||||
## Activity / Following / Liked tabs
|
||||
|
||||
@@ -49,8 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi
|
||||
|
||||
## Summary of backend work
|
||||
|
||||
1. A public query + controller route to read a user's stored `Social.UserAvatar`.
|
||||
2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
|
||||
3. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
||||
4. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
|
||||
5. Once (3) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.
|
||||
1. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept.
|
||||
2. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
||||
3. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
|
||||
4. An avatar-upload endpoint: `UploadAvatarCommand`/`UploadAvatarHandler` exist and `Database.Seed` sends them, but no controller action does, so a signed-in user has no way to set their own avatar. Needs a `multipart/form-data` route on the authenticated caller's own account (`IFormFile`, same validation the seeder's uploads pass through).
|
||||
5. Once (2) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const EmptyState = ({ message }: { message: string }) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import ProfileAvatar from './ProfileAvatar';
|
||||
|
||||
const profileAvatarDescription = `Avatar shown at the top of the public profile sidebar. It renders the photo from the public profile payload's \`avatarUrl\`, and shows the first two letters of the display name when that field is empty or the image fails to load.`;
|
||||
|
||||
const meta = {
|
||||
title: 'Users/ProfileAvatar',
|
||||
component: ProfileAvatar,
|
||||
tags: ['autodocs'],
|
||||
args: {
|
||||
displayName: 'Maren Iverson',
|
||||
avatarUrl: 'https://picsum.photos/id/1027/240/240',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: profileAvatarDescription,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof ProfileAvatar>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithPhoto: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const image = canvas.getByRole('img', { name: "Maren Iverson's avatar" });
|
||||
await expect(image).toHaveAttribute('src', 'https://picsum.photos/id/1027/240/240');
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutPhoto: Story = {
|
||||
args: { avatarUrl: '' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('MA')).toBeInTheDocument();
|
||||
await expect(canvas.queryByRole('img')).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const BrokenPhotoUrl: Story = {
|
||||
args: { avatarUrl: 'https://example.invalid/missing-avatar.jpg' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText('MA')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ProfileAvatarProps {
|
||||
avatarUrl: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the user's current avatar photo, falling back to their initials when the public
|
||||
* profile payload carries an empty `avatarUrl` or the hosted image fails to load.
|
||||
*/
|
||||
const ProfileAvatar = ({ avatarUrl, displayName }: ProfileAvatarProps) => {
|
||||
const [didImageFail, setDidImageFail] = useState(false);
|
||||
const showPhoto = avatarUrl.length > 0 && !didImageFail;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`avatar w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md ${
|
||||
showPhoto ? '' : 'avatar-placeholder'
|
||||
}`}
|
||||
>
|
||||
{showPhoto ? (
|
||||
<div className="w-full h-full rounded-full overflow-hidden">
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${displayName}'s avatar`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={() => setDidImageFail(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-secondary text-secondary-content w-full h-full rounded-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileAvatar;
|
||||
@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
|
||||
<div className="card-body gap-4 p-6">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{isOwnProfile ? (
|
||||
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
|
||||
|
||||
<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">
|
||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||
{location}
|
||||
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
|
||||
<div className="flex flex-col gap-2">
|
||||
{stats.map((stat) => (
|
||||
<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">
|
||||
{stat.value}
|
||||
</span>
|
||||
|
||||
@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
||||
{TABS.map((tab) => (
|
||||
<Tab
|
||||
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>
|
||||
|
||||
@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
|
||||
</div>
|
||||
)}
|
||||
{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}”
|
||||
</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}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
|
||||
) : (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,7 @@ const LikedTab = ({ liked }: LikedTabProps) => {
|
||||
<div className="h-[120px] bg-base-300" />
|
||||
<div className="card-body p-4 gap-1">
|
||||
<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">
|
||||
<StarRating value={item.rating} size="xs" />
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { data } from 'react-router';
|
||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
|
||||
import ProfileAvatar from '../components/profile/ProfileAvatar';
|
||||
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
||||
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
||||
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
||||
@@ -75,20 +76,12 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
||||
|
||||
return (
|
||||
<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="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
|
||||
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
|
||||
<div className="avatar avatar-placeholder w-full h-full">
|
||||
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
|
||||
<span className="text-2xl font-bold">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileAvatar avatarUrl={profile.avatarUrl} displayName={displayName} />
|
||||
|
||||
<ProfileSidebarCard
|
||||
displayName={displayName}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface PublicUserProfile {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
biography: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export const getPublicUserProfile = async (
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
biography: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
} = await res.json();
|
||||
|
||||
@@ -58,6 +60,7 @@ export const getPublicUserProfile = async (
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
biography: account.biography,
|
||||
avatarUrl: account.avatarUrl,
|
||||
createdAt: account.createdAt,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for
|
||||
// bio, location, avatar/cover photos, follower/following stats, and activity feeds.
|
||||
// location, cover photos, follower/following stats, and activity feeds.
|
||||
|
||||
export interface FillerProfileMeta {
|
||||
bio: string;
|
||||
|
||||
@@ -13,9 +13,15 @@ import './app.css';
|
||||
import Navbar from './components/ui/navbar/Navbar';
|
||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './features/auth/auth.server';
|
||||
import AiDisclosureDialog from './features/home/components/AiDisclosureDialog';
|
||||
import { defaultThemeName, parseThemeCookie } from './features/theme/themes';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'icon', href: '/favicon.ico', sizes: 'any' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
|
||||
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
|
||||
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
|
||||
{ rel: 'manifest', href: '/site.webmanifest' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
@@ -60,6 +66,7 @@ const App = ({ loaderData }: Route.ComponentProps) => {
|
||||
<>
|
||||
<Navbar auth={auth} />
|
||||
<ToastProvider />
|
||||
<AiDisclosureDialog />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
@@ -84,7 +91,7 @@ export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<main className="mx-auto max-w-7xl px-5 pt-10">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{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": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5103,9 +5103,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6775,9 +6775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7655,9 +7655,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -8972,9 +8972,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
|
||||
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
|
||||
@@ -6,6 +6,12 @@ const app = express();
|
||||
app.use('/assets', express.static('build/client/assets', { immutable: true, maxAge: '1y' }));
|
||||
app.use(express.static('build/client', { maxAge: '1h' }));
|
||||
|
||||
// Answers from Express alone so the container health check reports on this
|
||||
// process only, independent of whether the API is reachable yet.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.status(200).type('text/plain').send('ok');
|
||||
});
|
||||
|
||||
const requestHandler = createRequestHandler({
|
||||
build: () => import('./build/server/index.js'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user