diff --git a/.github/workflows/worktree-dev-test.yml b/.github/workflows/worktree-dev-test.yml
new file mode 100644
index 00000000..a8ee13ee
--- /dev/null
+++ b/.github/workflows/worktree-dev-test.yml
@@ -0,0 +1,33 @@
+name: Worktree development tooling
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'scripts/worktree-dev/**'
+ - '.gitignore'
+ - 'Makefile'
+ - '.github/workflows/worktree-dev-test.yml'
+ pull_request:
+ paths:
+ - 'scripts/worktree-dev/**'
+ - '.gitignore'
+ - 'Makefile'
+ - '.github/workflows/worktree-dev-test.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ lifecycle:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - name: Check syntax
+ run: |
+ bash -n scripts/worktree-dev/*.sh scripts/worktree-dev/talebound-worktree-dev
+ python3 -m py_compile scripts/worktree-dev/*.py
+ - name: Verify concurrent worktree isolation and cleanup
+ run: scripts/worktree-dev/test-concurrent-worktrees.sh
diff --git a/.gitignore b/.gitignore
index b24428df..35a60376 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,10 @@
.DS_Store
.worktrees/
+
+# Generated local worktree configuration and runtime state
+.talebound/
+.local-dev/
+.env.worktree
+backend/app.env.worktree
+frontend/.env.worktree
+__pycache__/
diff --git a/AGENTS.md b/AGENTS.md
index a83c2da5..ff3d2ea3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,3 +20,52 @@
- Prefer an existing suitable worktree. Otherwise branch from fetched `origin/main`
with an explicit task branch and destination path, using `--no-track`.
Use `git worktree add` if a managed tool cannot honor the required location.
+
+## Local worktree development
+
+- Read `scripts/worktree-dev/README.md` before changing local lifecycle tooling.
+- On Linux/WSL, bootstrap a new worktree with
+ `scripts/worktree-dev/bootstrap-worktree.sh`. This installs locked frontend
+ dependencies, starts this worktree's data services, and applies migrations.
+- Use `scripts/worktree-dev/talebound-worktree-dev up`, `status`, `logs`, and
+ `down` for the app lifecycle. Root Makefile aliases are also supported.
+ Do not create ad-hoc shared databases or use the legacy backend Compose file.
+- `setup` and `up` are idempotent; neither replaces existing database data.
+ `down` retains data. Before `down --purge-data`, check `status` and the exact
+ current worktree path. Purge deletes this worktree's labelled data and cache
+ volumes. Never remove unrelated or unlabelled Docker resources.
+- Every worktree has a separate PostgreSQL container/volume, Redis queue,
+ Mailpit inbox, auth key, and loopback address/ports. Open the frontend URL
+ reported by `status`; substituting `localhost` breaks origin/session isolation.
+- `.talebound/`, `.env.worktree`, `backend/app.env.worktree`, and
+ `frontend/.env.worktree` are generated local state. Never commit them, print
+ their secret values, or copy them between worktrees. Registry/lock files live
+ in the primary clone's Git metadata and must not be edited while tooling runs.
+- The launcher generates development configuration and does not copy another
+ checkout's environment. Email stays in Mailpit, Sentry is disabled, and
+ Cloudflare is unconfigured by default. Optional development image credentials
+ belong in `.talebound/integrations.env`; never add production fallbacks.
+- Frontend changes hot reload. For backend changes, use
+ `scripts/worktree-dev/talebound-worktree-dev restart backend`: the container
+ snapshots the source so migrations do not write generated SQL into the checkout.
+- Purge the current stack before removing a disposable worktree. Use
+ `git worktree remove` for Git-created worktrees. Preserve development data when
+ it has not been intentionally discarded.
+
+## Validation
+
+- For lifecycle, ownership, ports, or cleanup changes: run Bash syntax checks,
+ Python compilation, `status`, and
+ `scripts/worktree-dev/test-concurrent-worktrees.sh`. This creates disposable
+ worktrees under this workspace and verifies isolation and scoped cleanup.
+- For backend changes, use `scripts/worktree-dev/talebound-worktree-dev migrate`
+ when needed, then `test-backend`. Short tests skip the legacy real-email test.
+ Tests write fixtures to the current worktree database; never point them at a
+ shared or production database.
+- For frontend changes, run relevant lint/build checks and verify the affected
+ browser flow. Report failures honestly and do not suppress checks to make a
+ build appear green. Use `useAppSelector`/`useAppDispatch` from
+ `frontend/store/hooks.ts` for typed Redux access.
+- For worktree authentication/email changes, verify signup, local email delivery,
+ verification, login, and isolation from another running worktree. Report the
+ actual commands/results and any untested behavior before handoff.
diff --git a/Makefile b/Makefile
index e7c2005f..d00f370b 100644
--- a/Makefile
+++ b/Makefile
@@ -2,28 +2,47 @@
NODE ?= node
FRONTEND_YARN = cd frontend && $(NODE) .yarn/releases/yarn-3.6.1.cjs
-COMPOSE = docker compose -f backend/docker-compose.yaml
+WORKTREE_DEV = scripts/worktree-dev/talebound-worktree-dev
-.PHONY: help frontend-install frontend-dev frontend-build frontend-lint backend-dev backend-build backend-test backend-migrate api-generate services-up services-down
+.PHONY: help dev-setup dev-up dev-down dev-status dev-logs frontend-install frontend-dev frontend-build frontend-lint backend-dev backend-build backend-test backend-migrate api-generate services-up services-down
help:
+ @echo "dev-setup Install dependencies and migrate this worktree's local database"
+ @echo "dev-up Set up and start this worktree's complete local app"
+ @echo "dev-down Stop this worktree, retaining data"
+ @echo "dev-status Show this worktree's URLs and services"
+ @echo "dev-logs Show recent logs for this worktree"
@echo "frontend-install Install frontend dependencies from the lockfile"
- @echo "frontend-dev Start Next.js on port 4000"
+ @echo "frontend-dev Alias for dev-up (starts both applications)"
@echo "frontend-build Build the frontend"
@echo "frontend-lint Lint the frontend"
- @echo "backend-dev Start the Go API"
+ @echo "backend-dev Alias for dev-up (starts both applications)"
@echo "backend-build Compile all Go packages"
- @echo "backend-test Run Go tests (requires a migrated test database)"
- @echo "backend-migrate Apply database migrations to the configured database"
+ @echo "backend-test Run Go short tests in this worktree's container and database"
+ @echo "backend-migrate Apply migrations to this worktree's database"
@echo "api-generate Generate frontend API types from the local Swagger schema"
- @echo "services-up Start local PostgreSQL and Redis"
- @echo "services-down Stop local Compose services, retaining volumes"
+ @echo "services-up Start this worktree's PostgreSQL, Redis, and local email inbox"
+ @echo "services-down Alias for dev-down"
+
+dev-setup:
+ $(WORKTREE_DEV) setup
+
+dev-up:
+ $(WORKTREE_DEV) up
+
+dev-down:
+ $(WORKTREE_DEV) down
+
+dev-status:
+ $(WORKTREE_DEV) status
+
+dev-logs:
+ $(WORKTREE_DEV) logs
frontend-install:
$(FRONTEND_YARN) install --immutable
-frontend-dev:
- $(FRONTEND_YARN) dev
+frontend-dev: dev-up
frontend-build:
$(FRONTEND_YARN) build
@@ -31,23 +50,21 @@ frontend-build:
frontend-lint:
$(FRONTEND_YARN) lint
-backend-dev:
- $(MAKE) -C backend server
+backend-dev: dev-up
backend-build:
cd backend && go build ./...
backend-test:
- $(MAKE) -C backend test
+ $(WORKTREE_DEV) test-backend
backend-migrate:
- $(MAKE) -C backend migrateup
+ $(WORKTREE_DEV) migrate
api-generate:
$(FRONTEND_YARN) generate
services-up:
- $(COMPOSE) up -d postgres redis
+ $(WORKTREE_DEV) setup --services-only
-services-down:
- $(COMPOSE) down
+services-down: dev-down
diff --git a/README.md b/README.md
index b53077c4..1c387e58 100644
--- a/README.md
+++ b/README.md
@@ -10,37 +10,34 @@ The frontend and backend for Talebound, developed together in one repository.
## Development
-Use Node.js 22, Go 1.23 (as declared in `backend/go.mod`), GNU Make, and Docker
-with Compose. Yarn is checked into the frontend, so a global Yarn installation
-is not required. Run `make` to see the available commands.
+On Linux/WSL, use Bash, Python 3.9+, Git, and Docker with Compose v2. The supported
+worktree launcher runs Node.js 22, Go 1.23, PostgreSQL 15, Redis 7, and Mailpit in
+containers. No host Node, Go, Yarn, or Make installation is needed to run the app.
```sh
-make frontend-install
-make services-up
-make backend-migrate
+scripts/worktree-dev/bootstrap-worktree.sh
+scripts/worktree-dev/talebound-worktree-dev up
+scripts/worktree-dev/talebound-worktree-dev status
```
-The backend loads `backend/app.env`. Put local overrides in the ignored
-`backend/app.env.local`, which is loaded by the backend Makefile. Review the
-database configuration before running migrations. The supplied development
-database uses port 5432 and Redis uses port 6379.
-
-Create `frontend/.env.local` using the settings described in
-[the frontend README](frontend/README.md), including the API URL and the service
-keys needed for features you use. The existing development CORS setup uses
-`dev.talebound.net`; map it to `127.0.0.1` in your hosts file.
-
-Start each application in its own terminal:
-
-```sh
-make backend-dev
-make frontend-dev
-```
-
-The frontend runs at `http://dev.talebound.net:4000` and the HTTP API at
-`http://dev.talebound.net:8080`. Email, image delivery, and other integrations
-still need their own configuration. See [the backend README](backend/README.md)
-for its existing development tools and workflow.
+Each worktree gets its own database, Redis queue, email inbox, loopback address,
+ports, and authentication key. `status` prints the exact app and inbox URLs.
+Use those URLs: separate loopback addresses also keep browser cookies isolated.
+No hosts-file edits or AWS credentials are required. Register an account in the
+app and open the verification email in the local inbox.
+
+Bootstrap is idempotent and stops after installing dependencies and applying
+migrations; `up` starts both applications. `down` stops this worktree and retains
+data. Generated `.env.worktree`, `backend/app.env.worktree`,
+`frontend/.env.worktree`, and `.talebound/` are ignored local state. Do not copy
+them between worktrees. Optional development Cloudflare settings go in
+`.talebound/integrations.env`; uploads require those credentials.
+
+See [the worktree development guide](scripts/worktree-dev/README.md) for lifecycle
+commands, environment precedence, database access, cleanup, and validation.
+GNU Make aliases (`make dev-up`, `make dev-status`, `make dev-down`) are optional.
+The older `backend/Makefile` and `backend/docker-compose.yaml` contain historical
+single-repository commands; use the root launcher for local worktree development.
## Validation and API generation
@@ -52,7 +49,9 @@ make backend-test
make api-generate
```
-Backend tests require the migrated PostgreSQL database. API generation reads
+Backend tests use the current worktree's migrated PostgreSQL database. The
+frontend checks/API generator and `backend-build` use host Node.js 22/Go 1.23;
+their container equivalents are documented in the worktree guide. API generation reads
`backend/doc/swagger/talebound.swagger.json` directly; it no longer needs a
running backend or the old development server. After changing protobuf API
definitions, regenerate the backend schema with `make -C backend proto` using
@@ -70,12 +69,9 @@ existing imports and protobuf package identities. Go builds use the local
client without changes. The Yarn lockfile installed with `--immutable`.
- GitHub Actions workflows passed `actionlint`; Compose configuration and root
Makefile command paths were checked.
-- The imported frontend has existing build blockers: lint reports 28 errors
- and one warning, and a separate build with lint disabled fails type checking
- in `frontend/api/entities/useCreateEntityGroupContent.ts` because
- `entityGroupId` can be undefined. The source and generated client are unchanged
- by this migration. Frontend CI will report these failures until they are fixed;
- lint and type checking have not been disabled in the build configuration.
+- Frontend lint, TypeScript checking, and the production build pass with Node 22
+ and the checked-in Yarn lockfile. The inherited API/type mismatches and unused
+ code have been corrected; lint and type checking remain enabled.
## Builds and deployment
diff --git a/backend/mail/sender.go b/backend/mail/sender.go
index 56fe8d71..0dac7fdf 100644
--- a/backend/mail/sender.go
+++ b/backend/mail/sender.go
@@ -3,7 +3,9 @@ package mail
import (
"fmt"
"github.com/jordan-wright/email"
+ "net"
"net/smtp"
+ "strings"
)
const (
@@ -27,14 +29,25 @@ type AwsSesSender struct {
fromEmailAddress string
smtpUsername string
smtpPassword string
+ smtpEndpoint string
}
func NewAwsSesSender(name string, fromEmailAddress string, smtpUsername string, smtpPassword string) EmailSender {
+ return NewSMTPSender(name, fromEmailAddress, smtpServerAddress, smtpUsername, smtpPassword)
+}
+
+// NewSMTPSender accepts host:port, or a hostname using the submission port 587.
+// An unauthenticated local SMTP sink needs neither username nor password.
+func NewSMTPSender(name, fromEmailAddress, endpoint, smtpUsername, smtpPassword string) EmailSender {
+ if !strings.Contains(endpoint, ":") {
+ endpoint = net.JoinHostPort(endpoint, "587")
+ }
return &AwsSesSender{
name: name,
fromEmailAddress: fromEmailAddress,
smtpUsername: smtpUsername,
smtpPassword: smtpPassword,
+ smtpEndpoint: endpoint,
}
}
@@ -61,6 +74,13 @@ func (sender *AwsSesSender) SendEmail(
}
}
- smtpAuth := smtp.PlainAuth("", sender.smtpUsername, sender.smtpPassword, smtpAuthAddress)
- return e.Send(smtpServerAddress, smtpAuth)
+ host, _, err := net.SplitHostPort(sender.smtpEndpoint)
+ if err != nil {
+ return fmt.Errorf("invalid SMTP endpoint: %w", err)
+ }
+ var smtpAuth smtp.Auth
+ if sender.smtpUsername != "" || sender.smtpPassword != "" {
+ smtpAuth = smtp.PlainAuth("", sender.smtpUsername, sender.smtpPassword, host)
+ }
+ return e.Send(sender.smtpEndpoint, smtpAuth)
}
diff --git a/backend/mail/smtp_test.go b/backend/mail/smtp_test.go
new file mode 100644
index 00000000..cb0289cb
--- /dev/null
+++ b/backend/mail/smtp_test.go
@@ -0,0 +1,70 @@
+package mail
+
+import (
+ "bufio"
+ "fmt"
+ "net"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestSendEmailToLocalSMTPWithoutAuthentication(t *testing.T) {
+ listener, err := net.Listen("tcp", "127.0.0.1:0")
+ require.NoError(t, err)
+ t.Cleanup(func() { listener.Close() })
+ message := make(chan string, 1)
+ go func() {
+ conn, err := listener.Accept()
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(5 * time.Second))
+ fmt.Fprint(conn, "220 localhost SMTP test\r\n")
+ scanner := bufio.NewScanner(conn)
+ var body strings.Builder
+ inData := false
+ for scanner.Scan() {
+ line := scanner.Text()
+ if inData {
+ if line == "." {
+ message <- body.String()
+ fmt.Fprint(conn, "250 accepted\r\n")
+ inData = false
+ } else {
+ body.WriteString(line + "\n")
+ }
+ continue
+ }
+ switch {
+ case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"):
+ fmt.Fprint(conn, "250 localhost\r\n")
+ case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"):
+ fmt.Fprint(conn, "250 OK\r\n")
+ case line == "DATA":
+ inData = true
+ fmt.Fprint(conn, "354 send message\r\n")
+ case line == "QUIT":
+ fmt.Fprint(conn, "221 bye\r\n")
+ return
+ default:
+ fmt.Fprint(conn, "500 unsupported command\r\n")
+ }
+ }
+ }()
+
+ sender := NewSMTPSender("Talebound", "noreply@talebound.test", listener.Addr().String(), "", "")
+ require.NoError(t, sender.SendEmail("Local verification", "
Verify your email
",
+ []string{"developer@talebound.test"}, nil, nil, nil))
+ select {
+ case body := <-message:
+ require.Contains(t, body, "Subject: Local verification")
+ require.Contains(t, body, "developer@talebound.test")
+ require.Contains(t, body, "Verify your email")
+ case <-time.After(5 * time.Second):
+ t.Fatal("SMTP server did not receive the message")
+ }
+}
diff --git a/backend/main.go b/backend/main.go
index f613be22..6643b015 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -110,7 +110,7 @@ func runDBMigration(migrationURL string, dbSource string, migrationObjectsURL st
}
func runTaskProcessor(config util.Config, redisOpt asynq.RedisClientOpt, store db.Store) {
- mailer := mail.NewAwsSesSender(config.EmailSenderName, config.EmailSenderAddress, config.SmtpUsername, config.SmtpPassword)
+ mailer := mail.NewSMTPSender(config.EmailSenderName, config.EmailSenderAddress, config.SmtpEndpoint, config.SmtpUsername, config.SmtpPassword)
taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config)
log.Info().Msg("starting task processor")
err := taskProcessor.Start()
diff --git a/frontend/adapters/LocationAdapter.ts b/frontend/adapters/LocationAdapter.ts
index 8dcb597a..9dd197cd 100644
--- a/frontend/adapters/LocationAdapter.ts
+++ b/frontend/adapters/LocationAdapter.ts
@@ -29,7 +29,10 @@ export const locationSelectors = LocationAdapter.getSelectors(
export const selectLocationsByIds = createSelector(
[locationSelectors.selectEntities, (_, ids: number[]) => ids],
- (entities, ids) => ids.map((id) => entities[id]).filter(Boolean),
+ (entities, ids) =>
+ ids
+ .map((id) => entities[id])
+ .filter((location): location is PbLocation => location !== undefined),
);
// And then use the selectors to retrieve values
diff --git a/frontend/api/entities/useCreateEntityGroupContent.ts b/frontend/api/entities/useCreateEntityGroupContent.ts
index 71de2b58..db540941 100644
--- a/frontend/api/entities/useCreateEntityGroupContent.ts
+++ b/frontend/api/entities/useCreateEntityGroupContent.ts
@@ -10,8 +10,13 @@ export type CreateEntityGroupContentParams = {
};
export const useCreateEntityGroupContent = createMutation({
- mutationFn: async (variables: CreateEntityGroupContentParams) =>
- EntitiesCollection.entitiesCreateEntityGroupContent(variables.entityGroupId, variables.body),
+ mutationFn: async (variables: CreateEntityGroupContentParams) => {
+ if (!variables.entityGroupId) throw new Error('An entity group is required');
+ return EntitiesCollection.entitiesCreateEntityGroupContent(
+ variables.entityGroupId,
+ variables.body,
+ );
+ },
onSuccess: (data, variables) => {
const entityGroupContent = data.data;
if (entityGroupContent) {
@@ -29,7 +34,8 @@ export const useCreateEntityGroupContent = createMutation({
...oldData,
contents: [
...cachedData.map((c) => {
- if (!c.position) return c;
+ if (c.position === undefined || entityGroupContent.position === undefined)
+ return c;
let position = c.position;
if (
diff --git a/frontend/api/images/useGetImages.ts b/frontend/api/images/useGetImages.ts
index aac3e620..ac394581 100644
--- a/frontend/api/images/useGetImages.ts
+++ b/frontend/api/images/useGetImages.ts
@@ -14,8 +14,7 @@ export const useGetImages = createInfiniteQuery<
primaryKey: 'useGetImages',
queryFn: async ({ queryKey: [_primaryKey, variables], pageParam: offset }) => {
const { data } = await ImagesCollection.imagesGetImages({
- userId: variables?.userId,
- imageTypeId: variables?.imageTypeId,
+ ...variables,
limit: PAGE_SIZE,
offset,
});
diff --git a/frontend/api/images/useUploadImage.ts b/frontend/api/images/useUploadImage.ts
index 174259d5..d6a65146 100644
--- a/frontend/api/images/useUploadImage.ts
+++ b/frontend/api/images/useUploadImage.ts
@@ -1,4 +1,4 @@
-import { createMutation, inferData } from 'react-query-kit';
+import { createMutation } from 'react-query-kit';
import { ImagesCollection } from '../collections';
import { queryClient } from '../../pages/_app';
import { useGetImages } from './useGetImages';
@@ -14,69 +14,9 @@ export const useUploadImage = createMutation({
mutationFn: async (variables: ExpandedUploadImageRequest) =>
ImagesCollection.imagesUploadDefaultImage(variables),
- onSuccess: (data, variables) => {
- const queryKeys = [];
+ onSuccess: (data) => {
store.dispatch(imageAdapterSlice.actions.upsertImage(data.data));
-
- if (variables.userId && variables.imageTypeId) {
- queryKeys.push(
- useGetImages.getKey({
- userId: variables.userId,
- imageTypeId: variables.imageTypeId,
- }),
- );
- }
-
- if (variables.userId) {
- queryKeys.push(
- useGetImages.getKey({
- userId: variables.userId,
- }),
- );
- }
-
- if (variables.imageTypeId) {
- queryKeys.push(
- useGetImages.getKey({
- imageTypeId: variables.imageTypeId,
- }),
- );
- }
-
- queryKeys.push(useGetImages.getKey({}));
-
- queryKeys.forEach((queryKey) => {
- queryClient.setQueryData>(queryKey, (oldData) => {
- const newData = oldData
- ? {
- pages: oldData.pages.map((page, pageIndex) => {
- const images = page.images;
-
- if (images && pageIndex === 0) {
- images.unshift(data.data);
- }
-
- return {
- ...page,
- images,
- totalCount: (page.totalCount ?? 0) + 1,
- newOffset: page.newOffset ? page.newOffset + 1 : undefined,
- };
- }),
- pageParams: oldData.pageParams,
- }
- : {
- pages: [
- {
- images: [data.data],
- totalCount: 1,
- newOffset: undefined,
- },
- ],
- pageParams: [undefined],
- };
- return newData;
- });
- });
+ // Refetch filtered pages so their membership, counts, and offsets stay consistent.
+ return queryClient.invalidateQueries({ queryKey: useGetImages.getKey() });
},
});
diff --git a/frontend/api/locations/useCreateLocation.ts b/frontend/api/locations/useCreateLocation.ts
index f1390c0d..18b5f481 100644
--- a/frontend/api/locations/useCreateLocation.ts
+++ b/frontend/api/locations/useCreateLocation.ts
@@ -1,3 +1,7 @@
+import { queryClient } from '../../pages/_app';
+import { useGetLocations } from './useGetLocations';
+import { store } from '../../store';
+import { locationAdapterSlice } from '../../adapters/LocationAdapter';
import { createMutation } from 'react-query-kit';
import { LocationsCollection } from '../collections';
import { PbCreateLocationRequest } from '../../generated/api-types/data-contracts';
@@ -5,16 +9,8 @@ import { PbCreateLocationRequest } from '../../generated/api-types/data-contract
export const useCreateLocation = createMutation({
mutationFn: async (variables: PbCreateLocationRequest) =>
LocationsCollection.locationsCreateLocation(variables),
- onSuccess: (data, variables) => {
- const newLocation = data.data;
- if (newLocation && variables.moduleId) {
- // const getLocationsQueryKey = useGetLocations.getKey(variables.module);
- // queryClient.setQueryData>(
- // getLocationsQueryKey,
- // (locations) => {
- // return [...(locations ?? []), newLocation];
- // },
- // );
- }
+ onSuccess: (data) => {
+ store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data));
+ return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() });
},
});
diff --git a/frontend/api/locations/useDeleteBulkLocation.ts b/frontend/api/locations/useDeleteBulkLocation.ts
index 0f9ddc86..c47d5b4c 100644
--- a/frontend/api/locations/useDeleteBulkLocation.ts
+++ b/frontend/api/locations/useDeleteBulkLocation.ts
@@ -1,27 +1,19 @@
-import { createMutation, inferData } from 'react-query-kit';
+import { createMutation } from 'react-query-kit';
import { LocationsCollection } from '../collections';
import { queryClient } from '../../pages/_app';
-import { PbModule } from '../../generated/api-types/data-contracts';
import { useGetLocations } from './useGetLocations';
+import { store } from '../../store';
+import { locationAdapterSlice } from '../../adapters/LocationAdapter';
interface DeleteBulkLocationParams {
locationIds: number[];
- module?: PbModule;
}
export const useDeleteBulkLocation = createMutation({
mutationFn: async (variables: DeleteBulkLocationParams) =>
LocationsCollection.locationsDeleteBulkLocation({ locationIds: variables.locationIds }),
onSuccess: (_, variables) => {
- const deletedLocationIds = variables.locationIds;
- const keyParams = variables.module;
-
- if (deletedLocationIds && keyParams) {
- const getLocationsQueryKey = useGetLocations.getKey(keyParams);
- queryClient.setQueryData>(
- getLocationsQueryKey,
- (oldData) => oldData?.filter(({ id }) => (id ? !deletedLocationIds.includes(id) : true)),
- );
- }
+ store.dispatch(locationAdapterSlice.actions.removeLocations(variables.locationIds));
+ return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() });
},
});
diff --git a/frontend/api/locations/useDeleteLocation.ts b/frontend/api/locations/useDeleteLocation.ts
index 0f5f7994..dca0dc5e 100644
--- a/frontend/api/locations/useDeleteLocation.ts
+++ b/frontend/api/locations/useDeleteLocation.ts
@@ -1,3 +1,7 @@
+import { queryClient } from '../../pages/_app';
+import { useGetLocations } from './useGetLocations';
+import { store } from '../../store';
+import { locationAdapterSlice } from '../../adapters/LocationAdapter';
import { createMutation } from 'react-query-kit';
import { LocationsCollection } from '../collections';
@@ -8,4 +12,8 @@ interface DeleteLocationParams {
export const useDeleteLocation = createMutation({
mutationFn: async (variables: DeleteLocationParams) =>
LocationsCollection.locationsDeleteLocation(variables.locationId),
+ onSuccess: (_, variables) => {
+ store.dispatch(locationAdapterSlice.actions.removeLocation(variables.locationId));
+ return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() });
+ },
});
diff --git a/frontend/api/locations/useUpdateLocation.ts b/frontend/api/locations/useUpdateLocation.ts
index d4578d1a..8e074b12 100644
--- a/frontend/api/locations/useUpdateLocation.ts
+++ b/frontend/api/locations/useUpdateLocation.ts
@@ -1,40 +1,20 @@
import { LocationsCollection } from '../collections';
-import { createMutation, inferData } from 'react-query-kit';
+import { createMutation } from 'react-query-kit';
import { queryClient } from '../../pages/_app';
import { useGetLocations } from './useGetLocations';
-import { PbViewModule } from '../../generated/api-types/data-contracts';
import { store } from '../../store';
import { locationAdapterSlice } from '../../adapters/LocationAdapter';
type UpdateLocationParams = {
locationId: number;
- module?: PbViewModule;
body: Parameters[1];
};
export const useUpdateLocation = createMutation({
mutationFn: async (variables: UpdateLocationParams) =>
LocationsCollection.locationsUpdateLocation(variables.locationId, variables.body),
- onSuccess: (data, variables) => {
- const locationId = variables.locationId;
- const keyParams = variables.module;
-
- if (locationId && keyParams) {
- const getLocationsQueryKey = useGetLocations.getKey(keyParams);
- queryClient.setQueryData>(
- getLocationsQueryKey,
- (locations) => {
- return (
- locations?.map((l) => {
- if (l.id === locationId) {
- return data.data;
- }
- return l;
- }) ?? []
- );
- },
- );
- store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data));
- }
+ onSuccess: (data) => {
+ store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data));
+ return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() });
},
});
diff --git a/frontend/api/middlewareEnabled.ts b/frontend/api/middlewareEnabled.ts
index c00eb3a5..9868e001 100644
--- a/frontend/api/middlewareEnabled.ts
+++ b/frontend/api/middlewareEnabled.ts
@@ -1,4 +1,4 @@
-import { Middleware, QueryHook, SuspenseQueryHook } from 'react-query-kit';
+import { Middleware, QueryHook } from 'react-query-kit';
// import { Middleware, QueryHook, SuspenseQueryHook } from 'react-query-kit';
//
diff --git a/frontend/component-sections/Location/LocationForm.tsx b/frontend/component-sections/Location/LocationForm.tsx
index 91eb11fa..b98de1eb 100644
--- a/frontend/component-sections/Location/LocationForm.tsx
+++ b/frontend/component-sections/Location/LocationForm.tsx
@@ -3,10 +3,11 @@ import Textarea from '../../components/Textarea/Textarea';
import { useInput } from '../../hooks/useInput';
import Input from '../../components/Input/Input';
import { Col, Row } from '../../components/Flex/Flex';
-import Avatar from '../../components/Avatar/Avatar';
+import AvatarById from '../../components/AvatarById/AvatarById';
+import { useGetModuleId } from '../../api/modules/useGetModuleId';
import { Label } from '../../components/Typography/Label';
import ImageModal from '../../components/ImageModal/ImageModal';
-import { PbImage, PbModule, PbViewLocation } from '../../generated/api-types/data-contracts';
+import { PbImage, PbViewModule, PbLocation } from '../../generated/api-types/data-contracts';
import ErrorText from '../../components/ErrorText/ErrorText';
import { Button } from '../../components/Button/Button';
import { useCreateLocation } from '../../api/locations/useCreateLocation';
@@ -16,12 +17,12 @@ const textareaPlaceholder =
'Short description of the post. What information does this post contain?';
interface LocationFormProps {
- module: PbModule;
+ module: PbViewModule;
canChangeName?: boolean;
canChangeDescription?: boolean;
canChangeThumbnail?: boolean;
onFinishCallback?: () => void;
- location?: PbViewLocation;
+ location?: PbLocation;
}
const LocationForm: React.FC = ({
@@ -32,6 +33,7 @@ const LocationForm: React.FC = ({
onFinishCallback,
location,
}) => {
+ const { data: moduleId } = useGetModuleId({ variables: module });
const {
mutate: createLocation,
isPending: isPendingCreate,
@@ -48,9 +50,6 @@ const LocationForm: React.FC = ({
const [thumbnailImageId, setThumbnailImageId] = useState(
location ? location.thumbnailImageId : undefined,
);
- const [thumbnailImageUrl, setThumbnailImageUrl] = useState(
- location ? location.thumbnailImageUrl : undefined,
- );
const { value: name, onChange: onChangeName } = useInput(
location ? location.name ?? '' : '',
);
@@ -63,17 +62,16 @@ const LocationForm: React.FC = ({
}, []);
const changeThumbnailImage = useCallback(
- (image: PbImage) => {
- if (canChangeThumbnail) {
+ (image: PbImage | undefined) => {
+ if (canChangeThumbnail && image) {
setThumbnailImageId(image.id);
- setThumbnailImageUrl(image.baseUrl);
}
},
[canChangeThumbnail],
);
const createLocationHandler = useCallback(() => {
- if (module !== undefined) {
+ if (moduleId) {
const options = {
onSuccess: () => {
if (onFinishCallback) onFinishCallback();
@@ -92,7 +90,6 @@ const LocationForm: React.FC = ({
updateLocation(
{
locationId: location.id,
- module,
body,
},
options,
@@ -103,13 +100,14 @@ const LocationForm: React.FC = ({
createLocation(
{
...body,
+ moduleId,
},
options,
);
}
}
}, [
- module,
+ moduleId,
canChangeName,
name,
canChangeDescription,
@@ -151,11 +149,11 @@ const LocationForm: React.FC = ({
{canChangeThumbnail && (
Thumbnail
-
)}
diff --git a/frontend/component-sections/Location/LocationFormModal.tsx b/frontend/component-sections/Location/LocationFormModal.tsx
index ea2c66bc..ccda79b6 100644
--- a/frontend/component-sections/Location/LocationFormModal.tsx
+++ b/frontend/component-sections/Location/LocationFormModal.tsx
@@ -1,11 +1,11 @@
import React, { useCallback, useMemo } from 'react';
import Modal from '../../components/Modal/Modal';
import LocationForm from './LocationForm';
-import { PbModule, PbViewLocation } from '../../generated/api-types/data-contracts';
+import { PbViewModule, PbLocation } from '../../generated/api-types/data-contracts';
interface LocationFormModalProps {
- module: PbModule;
- location?: PbViewLocation;
+ module: PbViewModule;
+ location?: PbLocation;
trigger: React.ReactNode;
open?: boolean;
setOpen: (v: boolean) => void;
diff --git a/frontend/component-sections/Location/LocationList.tsx b/frontend/component-sections/Location/LocationList.tsx
index 3c03a902..1f107b36 100644
--- a/frontend/component-sections/Location/LocationList.tsx
+++ b/frontend/component-sections/Location/LocationList.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { Button } from '../../components/Button/Button';
import { useModuleRoute } from '../../hooks/useModuleRoute';
import { useGetLocations } from '../../api/locations/useGetLocations';
import LocationTable from './LocationTable';
@@ -9,13 +10,27 @@ interface LocationListProps {
}
const LocationList: React.FC = ({ canEdit }) => {
- const [module] = useModuleRoute(PbEntityType.ENTITY_TYPE_LOCATION);
+ const [module, moduleId] = useModuleRoute(PbEntityType.ENTITY_TYPE_LOCATION);
- const { data: locationsData = [] } = useGetLocations({ variables: module });
+ const {
+ data: locationsData,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ } = useGetLocations({ variables: { moduleId }, enabled: !!moduleId });
return (
-
+ page.locationIds) ?? []}
+ canEdit={canEdit}
+ module={module}
+ />
+ {hasNextPage && (
+ fetchNextPage()} loading={isFetchingNextPage}>
+ Load more locations
+
+ )}
);
};
diff --git a/frontend/component-sections/Location/LocationListSelection.tsx b/frontend/component-sections/Location/LocationListSelection.tsx
index bad6ab43..004975a2 100644
--- a/frontend/component-sections/Location/LocationListSelection.tsx
+++ b/frontend/component-sections/Location/LocationListSelection.tsx
@@ -1,4 +1,6 @@
import React from 'react';
+import { Button } from '../../components/Button/Button';
+import { useGetModuleId } from '../../api/modules/useGetModuleId';
import { useGetLocations } from '../../api/locations/useGetLocations';
import LocationTable from './LocationTable';
import { PbViewModule } from '../../generated/api-types/data-contracts';
@@ -9,9 +11,28 @@ interface LocationListSelectionProps {
}
const LocationListSelection: React.FC = ({ module }) => {
- const { data: locationsData = [] } = useGetLocations({ variables: module });
+ const { data: moduleId } = useGetModuleId({ variables: module });
+ const {
+ data: locationsData,
+ hasNextPage,
+ fetchNextPage,
+ isFetchingNextPage,
+ } = useGetLocations({ variables: { moduleId }, enabled: !!moduleId });
- return ;
+ return (
+
+ page.locationIds) ?? []}
+ module={module}
+ isSelectionTable={true}
+ />
+ {hasNextPage && (
+ fetchNextPage()} loading={isFetchingNextPage}>
+ Load more locations
+
+ )}
+
+ );
};
export default LocationListSelection;
diff --git a/frontend/component-sections/Location/LocationTable.tsx b/frontend/component-sections/Location/LocationTable.tsx
index 5722e8c2..035cfcce 100644
--- a/frontend/component-sections/Location/LocationTable.tsx
+++ b/frontend/component-sections/Location/LocationTable.tsx
@@ -1,12 +1,14 @@
import React, { useCallback, useMemo, useState } from 'react';
import { ColumnType } from 'antd/es/table';
-import Avatar from '../../components/Avatar/Avatar';
+import AvatarById from '../../components/AvatarById/AvatarById';
+import { useAppSelector } from '../../store/hooks';
+import { selectLocationsByIds } from '../../adapters/LocationAdapter';
import { Table } from 'antd';
import { TableProps } from 'antd/lib';
import { Button } from '../../components/Button/Button';
import { MdDelete, MdEdit } from 'react-icons/md';
import LocationFormModal from './LocationFormModal';
-import { PbViewLocation, PbViewModule } from '../../generated/api-types/data-contracts';
+import { PbLocation, PbViewModule } from '../../generated/api-types/data-contracts';
import { useDeleteBulkLocation } from '../../api/locations/useDeleteBulkLocation';
import ErrorText from '../../components/ErrorText/ErrorText';
import { Row } from '../../components/Flex/Flex';
@@ -15,7 +17,7 @@ import AlertDialog from '../../components/AlertDialog/AlertDialog';
import LocationTablePostCell from './LocationTablePostCell';
interface LocationTableProps {
- data: PbViewLocation[];
+ locationIds: number[];
canEdit?: boolean;
module: PbViewModule;
isSelectionTable?: boolean;
@@ -23,14 +25,15 @@ interface LocationTableProps {
}
const LocationTable: React.FC = ({
- data,
+ locationIds,
canEdit,
module,
isSelectionTable = false,
isSelectionMultiple = false,
}) => {
+ const data = useAppSelector((state) => selectLocationsByIds(state, locationIds));
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
- const [updateLocation, setUpdateLocation] = useState();
+ const [updateLocation, setUpdateLocation] = useState();
canEdit = canEdit && !isSelectionTable;
@@ -55,7 +58,7 @@ const LocationTable: React.FC = ({
setSelectedRowKeys(newSelectedRowKeys);
}, []);
- const rowSelection: TableProps['rowSelection'] = useMemo(
+ const rowSelection: TableProps['rowSelection'] = useMemo(
() =>
canEdit || isSelectionTable
? {
@@ -67,10 +70,10 @@ const LocationTable: React.FC = ({
[canEdit, selectedRowKeys, onSelectChange, isSelectionTable, isSelectionMultiple],
);
- const openEditModal = useCallback((record: PbViewLocation) => setUpdateLocation(record), []);
+ const openEditModal = useCallback((record: PbLocation) => setUpdateLocation(record), []);
const deleteLocationRecord = useCallback(
- (record: PbViewLocation) => {
+ (record: PbLocation) => {
const { id: locationId } = record;
if (locationId) {
deleteLocation({ locationId });
@@ -80,7 +83,7 @@ const LocationTable: React.FC = ({
);
const actionButtons = useCallback(
- (record: PbViewLocation) => {
+ (record: PbLocation) => {
const updateHandler = () => openEditModal(record);
const deleteHandler = () => deleteLocationRecord(record);
@@ -106,13 +109,13 @@ const LocationTable: React.FC = ({
[openEditModal, deleteLocationRecord],
);
- const columns: ColumnType[] = useMemo(() => {
- const cols: ColumnType[] = [
+ const columns: ColumnType[] = useMemo(() => {
+ const cols: ColumnType[] = [
{
title: '',
- key: 'thumbnailImageUrl',
- dataIndex: 'thumbnailImageUrl',
- render: (value: string) => ,
+ key: 'thumbnailImageId',
+ dataIndex: 'thumbnailImageId',
+ render: (value: number | undefined) => ,
},
{
title: 'Name',
@@ -130,18 +133,16 @@ const LocationTable: React.FC = ({
},
{
title: 'Post',
- key: 'postTitle',
- dataIndex: 'postTitle',
+ key: 'postId',
+ dataIndex: 'postId',
render: (_, record) =>
record.id && (
),
- sorter: (a, b) => (a.postTitle ?? '').localeCompare(b.postTitle ?? ''),
width: '150px',
},
];
@@ -157,14 +158,14 @@ const LocationTable: React.FC = ({
return cols;
}, [canEdit, actionButtons]);
- const summary: TableProps['summary'] = useCallback(() => {
+ const summary: TableProps['summary'] = useCallback(() => {
if (!canEdit) return undefined;
if (selectedRowKeys.length === 0) return undefined;
const deleteBulkHandler = () => {
const locationIds = selectedRowKeys.map((key) => parseInt(key.toString()));
deleteBulkLocations(
- { locationIds, module },
+ { locationIds },
{
onSuccess: () => {
setSelectedRowKeys([]);
@@ -197,10 +198,10 @@ const LocationTable: React.FC = ({
);
- }, [canEdit, deleteBulkLocations, errorDeleteBulk, isPendingDeleteBulk, module, selectedRowKeys]);
+ }, [canEdit, deleteBulkLocations, errorDeleteBulk, isPendingDeleteBulk, selectedRowKeys]);
- const onRow: TableProps['onRow'] = useCallback(
- (record: PbViewLocation) => {
+ const onRow: TableProps['onRow'] = useCallback(
+ (record: PbLocation) => {
if (!isSelectionTable) return {};
return {
@@ -214,7 +215,7 @@ const LocationTable: React.FC = ({
return (
<>
-
+
loading={isPendingDelete}
rowSelection={rowSelection}
showSorterTooltip={false}
diff --git a/frontend/component-sections/Location/LocationTablePostCell.tsx b/frontend/component-sections/Location/LocationTablePostCell.tsx
index 313a3f79..97ffebac 100644
--- a/frontend/component-sections/Location/LocationTablePostCell.tsx
+++ b/frontend/component-sections/Location/LocationTablePostCell.tsx
@@ -1,3 +1,4 @@
+import { usePost } from '../../hooks/usePost';
import React, { useCallback, useMemo, useState } from 'react';
import { MdAdd } from 'react-icons/md';
import { useCreateLocationPost } from '../../api/locations/useCreateLocationPost';
@@ -8,16 +9,15 @@ import ErrorText from '../../components/ErrorText/ErrorText';
interface LocationTablePostCellProps {
locationId: number;
postId?: number;
- postTitle?: string;
canEdit?: boolean;
}
const LocationTablePostCell: React.FC = ({
locationId,
postId,
- postTitle,
canEdit,
}) => {
+ const { post } = usePost(postId);
const [assignPostModal, setAssignPostModal] = useState(false);
const {
@@ -62,7 +62,7 @@ const LocationTablePostCell: React.FC = ({
>
);
}
- return {postTitle}
;
+ return {post?.title}
;
};
export default LocationTablePostCell;
diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx
index d8bf0bf8..4267de52 100644
--- a/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx
+++ b/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { PbViewMapLayer } from '../../../../generated/api-types/data-contracts';
import { Row } from '../../../../components/Flex/Flex';
-import ContentSection from '../../../../components/ContentSection/ContentSection';
import { Reorder, useDragControls } from 'framer-motion';
import { useUpdateMapLayer } from '../../../../api/maps/useUpdateMapLayer';
import { DragHandle } from '../../../../screens/menus/MenuAdministration/menuAdministrationComponents';
@@ -32,7 +31,6 @@ const MapLayerSectionDraggable: React.FC = ({
const {
mutate: updateMapLayer,
isPending: isPendingUpdate,
- isError: isErrorUpdate,
error: errorUpdate,
} = useUpdateMapLayer({
onSettled,
diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx
index dfbe1009..807b3ecf 100644
--- a/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx
+++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx
@@ -12,7 +12,7 @@ interface MapPinAdministrationContentProps {
mapId: number;
}
-const MapPinAdministrationContent: React.FC = ({ mapId }) => {
+const MapPinAdministrationContent: React.FC = () => {
return (
diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx
index 8d40075c..0d52d55c 100644
--- a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx
+++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx
@@ -23,9 +23,7 @@ interface MapPinTypeAdministrationContentProps {
mapId: number;
}
-const MapPinTypeAdministrationContent: React.FC = ({
- mapId,
-}) => {
+const MapPinTypeAdministrationContent: React.FC = () => {
const [state, dispatch] = useReducer(
mapPinTypeAdministrationReducer,
mapPinTypeAdministrationInitialState,
diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx
index f7a46e11..289c841d 100644
--- a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx
+++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx
@@ -1,8 +1,6 @@
-import React, { useMemo } from 'react';
+import React from 'react';
import { Col, Row } from '../../../../../components/Flex/Flex';
import { TitleH2 } from '../../../../../components/Typography/Title';
-import { useUrlModuleId } from '../../../../../hooks/useUrlModuleId';
-import { useGetMapPinTypesAndGroups } from '../../../../../api/maps/useGetMapPinTypesAndGroups';
import { Label } from '../../../../../components/Typography/Label';
import { Button } from '../../../../../components/Button/Button';
import { Text } from '../../../../../components/Typography/Text';
@@ -13,17 +11,7 @@ interface PinTypeActionsProps {
pinTypeId: number;
}
-const PinTypeActions: React.FC = ({ pinTypeId }) => {
- const moduleId = useUrlModuleId();
- const { data: mapPinTypesAndGroups, isFetching: isPending } = useGetMapPinTypesAndGroups({
- variables: moduleId,
- });
-
- const pinData = useMemo(
- () => (mapPinTypesAndGroups?.pinTypes ?? []).find((x) => x.id === pinTypeId),
- [mapPinTypesAndGroups, pinTypeId],
- );
-
+const PinTypeActions: React.FC = () => {
return (
Actions
diff --git a/frontend/component-sections/Map/MapLayout/MapLayout.tsx b/frontend/component-sections/Map/MapLayout/MapLayout.tsx
index 612194f8..d44b3563 100644
--- a/frontend/component-sections/Map/MapLayout/MapLayout.tsx
+++ b/frontend/component-sections/Map/MapLayout/MapLayout.tsx
@@ -27,8 +27,8 @@ interface MapLayoutProps {
}
const MapLayout: React.FC = ({ mapId, canEdit, allLayersByDefault = false }) => {
- const { map: mapData, isFetching: isPendingMap } = useMap(mapId);
- const { data: mapLayers, isFetching: isPendingMapLayers } = useGetMapLayers({ variables: mapId });
+ const { map: mapData } = useMap(mapId);
+ const { data: mapLayers } = useGetMapLayers({ variables: mapId });
const wrapperRef = useRef(null);
const [displayedLayers, setDisplayedLayers] = useState({});
diff --git a/frontend/component-sections/Map/MapList.tsx b/frontend/component-sections/Map/MapList.tsx
index c9d55abd..0fce4dad 100644
--- a/frontend/component-sections/Map/MapList.tsx
+++ b/frontend/component-sections/Map/MapList.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ReduxState } from '../../store';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../store/hooks';
import { EntityTableType, Tables } from '../../utils/types/tables';
import { PAGE_SIZE_MAPS, useGetMaps } from '../../api/maps/useGetMaps';
import { selectMapsByIds } from '../../adapters/MapAdapter';
@@ -35,7 +35,7 @@ const MapList: React.FC = ({
[mapsDataPages],
);
- const mapsData = useSelector((state: ReduxState) => selectMapsByIds(state, mapIds), [mapIds]);
+ const mapsData = useAppSelector((state: ReduxState) => selectMapsByIds(state, mapIds));
useEffect(() => {
if (
diff --git a/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx b/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx
index d2b4e546..714ba7eb 100644
--- a/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx
+++ b/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx
@@ -16,7 +16,7 @@ interface MapSidebarPinsProps {
displayedLayers: DisplayedLayers;
}
-const MapSidebarPins: React.FC = ({ mapId, displayedLayers, canEdit }) => {
+const MapSidebarPins: React.FC = ({ mapId, canEdit }) => {
const { data: mapPins, isFetching: isPendingMapPins } = useGetMapPins({ variables: mapId });
const [modalOpenPins, setModalOpenPins] = useState(false);
diff --git a/frontend/component-sections/Menu/MenuCategoryPage.tsx b/frontend/component-sections/Menu/MenuCategoryPage.tsx
index 36484322..0a5a7da6 100644
--- a/frontend/component-sections/Menu/MenuCategoryPage.tsx
+++ b/frontend/component-sections/Menu/MenuCategoryPage.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useMemo } from 'react';
import { useRouter } from 'next/router';
import MenuCategory from '../../screens/menus/MenuCategory/MenuCategory';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { setOpenedUrlPrefix } from '../../screens/menus/MenuCategory/menuCategorySlice';
interface MenuCategoryProps {
@@ -17,7 +17,7 @@ const MenuCategoryPage: React.FC = ({
menuId,
canEdit,
}) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const router = useRouter();
const m = router.query['menuCategory'];
diff --git a/frontend/component-sections/Post/ModulePosts.tsx b/frontend/component-sections/Post/ModulePosts.tsx
index db88be8d..cc51da6a 100644
--- a/frontend/component-sections/Post/ModulePosts.tsx
+++ b/frontend/component-sections/Post/ModulePosts.tsx
@@ -1,7 +1,6 @@
import React from 'react';
import { isModuleCollaborator, useMyModuleRole } from '../../hooks/useModuleAdmins';
import ModuleLayout from '../../components/Layout/ModuleLayout';
-import MapsScreen from '../../screens/menus/Maps/MapsScreen';
import PostsScreen from '../../screens/menus/Posts/PostsScreen';
interface ModulePostsProps {
diff --git a/frontend/component-sections/Post/PostFormOld.tsx b/frontend/component-sections/Post/PostFormOld.tsx
deleted file mode 100644
index e52d420f..00000000
--- a/frontend/component-sections/Post/PostFormOld.tsx
+++ /dev/null
@@ -1,149 +0,0 @@
-import React, { useCallback, useState } from 'react';
-import ContentSection from '../../components/ContentSection/ContentSection';
-import Textarea from '../../components/Textarea/Textarea';
-import { useInput } from '../../hooks/useInput';
-import Input from '../../components/Input/Input';
-import { Col, Row } from '../../components/Flex/Flex';
-import Avatar from '../../components/Avatar/Avatar';
-import { Label } from '../../components/Typography/Label';
-import ImageModal from '../../components/ImageModal/ImageModal';
-import { PbImage } from '../../generated/api-types/data-contracts';
-import ErrorText from '../../components/ErrorText/ErrorText';
-import { Button } from '../../components/Button/Button';
-import { useCreateMenuItemPost } from '../../api/menus/useCreateMenuItemPost';
-
-const textareaPlaceholder =
- 'Short description of the post. What information does this post contain?';
-
-interface PostFormOldProps {
- menuId?: number;
- menuItemId?: number;
- position?: number;
- canChangeTitle?: boolean;
- canChangeDescription?: boolean;
- canChangeThumbnail?: boolean;
- onFinishCallback?: () => void;
-}
-
-const PostFormOld: React.FC = ({
- menuId,
- menuItemId,
- position,
- canChangeTitle = true,
- canChangeDescription = true,
- canChangeThumbnail = true,
- onFinishCallback,
-}) => {
- const { mutate: createMenuItemPost, isPending, error } = useCreateMenuItemPost();
-
- const [showImageModal, setShowImageModal] = useState(false);
-
- const [thumbnailImageId, setThumbnailImageId] = useState();
- const [thumbnailImageUrl, setThumbnailImageUrl] = useState();
- const { value: title, onChange: onChangeTitle } = useInput('');
- const { value: description, onChange } = useInput('');
-
- const toggleImageModal = useCallback(() => {
- setShowImageModal((p) => !p);
- }, []);
-
- const changeThumbnailImage = useCallback(
- (image: PbImage) => {
- if (canChangeThumbnail) {
- setThumbnailImageId(image.id);
- setThumbnailImageUrl(image.baseUrl);
- }
- },
- [canChangeThumbnail],
- );
-
- const createPostHandler = useCallback(() => {
- if (menuId && menuItemId !== undefined) {
- createMenuItemPost(
- {
- menuId,
- menuItemId,
- body: {
- title: canChangeTitle ? title : undefined,
- position,
- shortDescription: canChangeDescription ? description : undefined,
- imageThumbnailId: thumbnailImageId,
- },
- },
- {
- onSuccess: () => {
- if (onFinishCallback) onFinishCallback();
- },
- },
- );
- }
- }, [
- createMenuItemPost,
- menuId,
- menuItemId,
- position,
- canChangeTitle,
- title,
- canChangeDescription,
- description,
- thumbnailImageId,
- ]);
-
- const pending = isPending;
-
- if (!canChangeTitle && !canChangeDescription && !canChangeThumbnail) return null;
-
- return (
- <>
-
-
- {canChangeTitle && (
-
- )}
- {canChangeDescription && (
-
- )}
-
- Create
-
-
-
- {canChangeThumbnail && (
-
- Thumbnail
-
-
- )}
-
-
- >
- );
-};
-
-export default PostFormOld;
diff --git a/frontend/component-sections/Post/PostList.tsx b/frontend/component-sections/Post/PostList.tsx
index ca61798e..4c74740f 100644
--- a/frontend/component-sections/Post/PostList.tsx
+++ b/frontend/component-sections/Post/PostList.tsx
@@ -3,7 +3,7 @@ import { PAGE_SIZE_POSTS, useGetPosts } from '../../api/posts/useGetPosts';
import PostsTable from './PostsTable';
import { selectPostsByIds } from '../../adapters/PostAdapter';
import { ReduxState } from '../../store';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../store/hooks';
import { EntityTableType, Tables } from '../../utils/types/tables';
interface PostListProps {
@@ -35,7 +35,7 @@ const PostList: React.FC = ({
[postsDataPages],
);
- const postsData = useSelector((state: ReduxState) => selectPostsByIds(state, postIds), [postIds]);
+ const postsData = useAppSelector((state: ReduxState) => selectPostsByIds(state, postIds));
useEffect(() => {
if (
diff --git a/frontend/component-sections/UserProfile/UserProfile.tsx b/frontend/component-sections/UserProfile/UserProfile.tsx
index d541a6c6..9adf1882 100644
--- a/frontend/component-sections/UserProfile/UserProfile.tsx
+++ b/frontend/component-sections/UserProfile/UserProfile.tsx
@@ -10,7 +10,7 @@ import { Button } from '../../components/Button/Button';
import { useGetAverageUserEvaluationByType } from '../../api/users/useGetAverageUserEvaluationByType';
import { PbEvaluationType } from '../../generated/api-types/data-contracts';
import Evaluation from '../../components/Evaluation/Evaluation';
-import Avatar from '../../components/Avatar/Avatar';
+import AvatarById from '../../components/AvatarById/AvatarById';
import Loading from '../../components/Loading/Loading';
export interface UserProfileProps {
@@ -40,7 +40,7 @@ const UserProfile: React.FC = ({ userId }) => {
-
+
Edit
diff --git a/frontend/component-sections/UserSettings/AvatarChange.tsx b/frontend/component-sections/UserSettings/AvatarChange.tsx
index 77de3c96..01602484 100644
--- a/frontend/component-sections/UserSettings/AvatarChange.tsx
+++ b/frontend/component-sections/UserSettings/AvatarChange.tsx
@@ -6,21 +6,22 @@ import ContentSection from '../../components/ContentSection/ContentSection';
import { UploadUserAvatarRequest, useUploadUserAvatar } from '../../api/users/useUploadUserAvatar';
import { useAuth } from '../../hooks/useAuth';
import { updateUser } from '../../utils/auth/userSlice';
-import { useDispatch } from 'react-redux';
-import Avatar from '../../components/Avatar/Avatar';
+import { useAppDispatch } from '../../store/hooks';
+import AvatarById from '../../components/AvatarById/AvatarById';
+import { imageAdapterSlice } from '../../adapters/ImageAdapter';
import Loading from '../../components/Loading/Loading';
import { fileInputChanged } from '../../utils/functions/fileInputChanged';
const AvatarChange: React.FC = () => {
const { user } = useAuth();
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const doUploadAvatar = useUploadUserAvatar({
onSuccess: (data) => {
+ if (data.data.image) dispatch(imageAdapterSlice.actions.upsertImage(data.data.image));
dispatch(
updateUser({
imgId: data.data.image?.id,
- img: data.data.image,
}),
);
},
@@ -64,7 +65,7 @@ const AvatarChange: React.FC = () => {
{doUploadAvatar.isPending ? : 'Upload'}
-
+
);
};
diff --git a/frontend/component-sections/header/Header.tsx b/frontend/component-sections/header/Header.tsx
index 3aced0ed..e8b9b34e 100644
--- a/frontend/component-sections/header/Header.tsx
+++ b/frontend/component-sections/header/Header.tsx
@@ -5,7 +5,7 @@ import { HeaderTransparentSection } from '../../components/HeaderTransparentSect
import { useAuth } from '../../hooks/useAuth';
import { UserDiamond } from './ControlPanel/UserDiamond';
import Link from 'next/link';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../store/hooks';
import { ReduxState } from '../../store';
import { DEFAULT_AVATAR_URL } from '../../utils/constants';
import UserModuleQuests from './UserModules/UserModuleQuests';
@@ -28,11 +28,11 @@ const BaseHeader = styled('div', {
const Header: React.FC = () => {
const { user } = useAuth();
- const headerImage = useSelector((state: ReduxState) => state.global.headerImage);
+ const headerImage = useAppSelector((state: ReduxState) => state.global.headerImage);
const { image } = useImage(user?.imgId ?? 0);
- const userId = useSelector((state) => state.auth.user?.id);
- const modules = useSelector((state) => moduleSelectors.selectAll(state));
+ const userId = useAppSelector((state) => state.auth.user?.id);
+ const modules = useAppSelector((state) => moduleSelectors.selectAll(state));
const { data: moduleData } = useGetUserModules({
variables: userId ?? 0,
diff --git a/frontend/component-sections/header/Menu.tsx b/frontend/component-sections/header/Menu.tsx
index 8d77fd73..fd71e73c 100644
--- a/frontend/component-sections/header/Menu.tsx
+++ b/frontend/component-sections/header/Menu.tsx
@@ -8,8 +8,7 @@ import { Row } from '../../components/Flex/Flex';
import Link from 'next/link';
import { useUserRole } from '../../hooks/useUserRole';
import { isAtLeastModerator, UserRole } from '../../utils/auth/userUtils';
-import { RiShieldStarLine, RiShieldUserLine } from 'react-icons/ri';
-import { TbBell, TbMessage, TbShieldHalf, TbShieldHalfFilled, TbShieldStar } from 'react-icons/tb';
+import { TbBell, TbMessage, TbShieldHalfFilled, TbShieldStar } from 'react-icons/tb';
const StyledNavbar = styled('nav', {
backgroundColor: 'rgba(255, 255, 255, 0.8)',
diff --git a/frontend/component-sections/homepage/Login.tsx b/frontend/component-sections/homepage/Login.tsx
index 8bf7c528..cb208756 100644
--- a/frontend/component-sections/homepage/Login.tsx
+++ b/frontend/component-sections/homepage/Login.tsx
@@ -7,7 +7,7 @@ import Link from 'next/link';
import { useLogin } from '../../api/auth/useLogin';
import { useRouter } from 'next/router';
import { setUser } from '../../utils/auth/userSlice';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { Client } from 'react-hydration-provider';
import { useInput } from '../../hooks/useInput';
import { styled } from '../../styles/stitches.config';
@@ -89,7 +89,7 @@ const ForgotPassword = styled(Link, {
const Login: React.FC = () => {
const router = useRouter();
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const login = useLogin({
onSuccess: (data) => {
diff --git a/frontend/components/AvatarById/AvatarById.tsx b/frontend/components/AvatarById/AvatarById.tsx
index 7503bb73..a4ec60e0 100644
--- a/frontend/components/AvatarById/AvatarById.tsx
+++ b/frontend/components/AvatarById/AvatarById.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { Suspense } from 'react';
import Avatar, { AvatarProps } from '../Avatar/Avatar';
import { useImage } from '../../hooks/useImage';
@@ -6,10 +6,16 @@ interface AvatarByIdProps extends Omit {
imageId?: number;
}
-const AvatarById: React.FC = ({ imageId, ...rest }) => {
+const AvatarContent: React.FC = ({ imageId, ...rest }) => {
const { image } = useImage(imageId);
return ;
};
+const AvatarById: React.FC = ({ imageId, ...rest }) => (
+ }>
+
+
+);
+
export default AvatarById;
diff --git a/frontend/components/Editor/nodes/ImageModal/EditorImageAttributes.tsx b/frontend/components/Editor/nodes/ImageModal/EditorImageAttributes.tsx
index 347f545b..4c1236c9 100644
--- a/frontend/components/Editor/nodes/ImageModal/EditorImageAttributes.tsx
+++ b/frontend/components/Editor/nodes/ImageModal/EditorImageAttributes.tsx
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect } from 'react';
import { useInput } from '../../../../hooks/useInput';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import { updateInlineImagePayload } from './editorImageModalSlice';
import { Col } from '../../../Flex/Flex';
@@ -20,11 +20,11 @@ const options: SelectOptions = {
};
const EditorImageAttributes: React.FC = () => {
- const dispatch = useDispatch();
- const showCaption = useSelector(
+ const dispatch = useAppDispatch();
+ const showCaption = useAppSelector(
(state: ReduxState) => state.editorImageModal.inlineImagePayload.showCaption,
);
- const position = useSelector(
+ const position = useAppSelector(
(state: ReduxState) => state.editorImageModal.inlineImagePayload.position,
);
const { value: altValue, onChange: onChangeAlt } = useInput('');
diff --git a/frontend/components/Editor/nodes/ImageModal/EditorImageModalContent.tsx b/frontend/components/Editor/nodes/ImageModal/EditorImageModalContent.tsx
index 06c6523c..19259a2b 100644
--- a/frontend/components/Editor/nodes/ImageModal/EditorImageModalContent.tsx
+++ b/frontend/components/Editor/nodes/ImageModal/EditorImageModalContent.tsx
@@ -6,7 +6,7 @@ import EditorImageModalTabUrl from './EditorImageModalTabUrl';
import ImageModalTabUpload from '../../../ImageModal/ImageModalTabUpload';
import ImageModalTabYourImages from '../../../ImageModal/ImageModalTabYourImages';
import { INSERT_INLINE_IMAGE_COMMAND } from '../../plugins/InlineImagePlugin';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import EditorImageAttributes from './EditorImageAttributes';
import { styled } from '../../../../styles/stitches.config';
@@ -50,10 +50,10 @@ const EditorImageModalContent: React.FC = ({
nodeKey,
setOpen,
}) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const [activeTab, setActiveTab] = React.useState(ImageModalTabs.Url);
- const payload = useSelector((state: ReduxState) => state.editorImageModal.inlineImagePayload);
- const selectedVariant = useSelector((state: ReduxState) => state.imageModal.selectedVariant);
+ const payload = useAppSelector((state: ReduxState) => state.editorImageModal.inlineImagePayload);
+ const selectedVariant = useAppSelector((state: ReduxState) => state.imageModal.selectedVariant);
const [isCloudflareImage, setIsCloudflareImage] = React.useState(false);
useEffect(() => {
diff --git a/frontend/components/Editor/nodes/ImageModal/EditorImageModalTabUrl.tsx b/frontend/components/Editor/nodes/ImageModal/EditorImageModalTabUrl.tsx
index 91be269c..4579e302 100644
--- a/frontend/components/Editor/nodes/ImageModal/EditorImageModalTabUrl.tsx
+++ b/frontend/components/Editor/nodes/ImageModal/EditorImageModalTabUrl.tsx
@@ -2,13 +2,13 @@ import React, { useEffect } from 'react';
import { useInput } from '../../../../hooks/useInput';
import Input from '../../../Input/Input';
import { Row } from '../../../Flex/Flex';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import { updateInlineImagePayload } from './editorImageModalSlice';
import { ReduxState } from '../../../../store';
const EditorImageModalTabUrl: React.FC = () => {
- const dispatch = useDispatch();
- const src = useSelector((state: ReduxState) => state.editorImageModal.inlineImagePayload.src);
+ const dispatch = useAppDispatch();
+ const src = useAppSelector((state: ReduxState) => state.editorImageModal.inlineImagePayload.src);
const { value: urlValue, onChange: onChangeUrl } = useInput(src);
useEffect(() => {
diff --git a/frontend/components/Editor/nodes/InlineImageComponent/InlineImageComponent.tsx b/frontend/components/Editor/nodes/InlineImageComponent/InlineImageComponent.tsx
index 0b49f6f0..54eeba60 100644
--- a/frontend/components/Editor/nodes/InlineImageComponent/InlineImageComponent.tsx
+++ b/frontend/components/Editor/nodes/InlineImageComponent/InlineImageComponent.tsx
@@ -35,7 +35,7 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
import { useSharedHistoryContext } from '../../context/SharedHistoryContext';
import EditorImageModal from '../ImageModal/EditorImageModal';
import { updateInlineImagePayload } from '../ImageModal/editorImageModalSlice';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../../../store/hooks';
import { EditorImageModalMode } from '../ImageModal/editorImageModalLib';
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
@@ -122,12 +122,12 @@ export default function InlineImageComponent({
position: ImagePosition;
resizable?: boolean;
}): JSX.Element {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const { historyState } = useSharedHistoryContext();
const imageRef = useRef(null);
const buttonRef = useRef(null);
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
- const [isResizing, setIsResizing] = useState(false);
+ const isResizing = false;
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState(
null,
diff --git a/frontend/components/Editor/nodes/InlineImageComponent/LazyImage.tsx b/frontend/components/Editor/nodes/InlineImageComponent/LazyImage.tsx
index 86773a4f..4fb6299b 100644
--- a/frontend/components/Editor/nodes/InlineImageComponent/LazyImage.tsx
+++ b/frontend/components/Editor/nodes/InlineImageComponent/LazyImage.tsx
@@ -13,7 +13,7 @@ const StyledLazyImage = styled('img', {
position: {
left: {},
right: {},
- full: {},
+ none: {},
},
grabbable: {
diff --git a/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarButtonBlockType.tsx b/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarButtonBlockType.tsx
index a1d195a7..49a6d6fe 100644
--- a/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarButtonBlockType.tsx
+++ b/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarButtonBlockType.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useState } from 'react';
+import React, { useEffect } from 'react';
import {
BsChatSquareQuote,
BsChevronDown,
@@ -10,7 +10,6 @@ import {
BsTypeH2,
BsTypeH3,
} from 'react-icons/bs';
-import { createPortal } from 'react-dom';
import ToolbarDropdownBlockType from './ToolbarDropdownBlockType';
import { Divider, ToolbarItemButton } from './componentsToolbar';
import { LexicalEditor } from 'lexical';
@@ -46,8 +45,6 @@ export const TextWrapper = styled('span', {
textAlign: 'left',
});
-const supportedBlockTypes = new Set(['paragraph', 'quote', 'code', 'h1', 'h2', 'ul', 'ol']);
-
export type BlockType =
| 'code'
| 'h1'
diff --git a/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarDropdownBlockType.tsx b/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarDropdownBlockType.tsx
index f982ad6a..343480f3 100644
--- a/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarDropdownBlockType.tsx
+++ b/frontend/components/Editor/plugins/ToolbarPlugin/ToolbarDropdownBlockType.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useRef } from 'react';
+import React, { useCallback } from 'react';
import { $getSelection, $isRangeSelection, $createParagraphNode, LexicalEditor } from 'lexical';
import { $wrapNodes } from '@lexical/selection';
import {
diff --git a/frontend/components/ImageModal/ImageModal.tsx b/frontend/components/ImageModal/ImageModal.tsx
index 4542e7ef..78e8eb83 100644
--- a/frontend/components/ImageModal/ImageModal.tsx
+++ b/frontend/components/ImageModal/ImageModal.tsx
@@ -3,7 +3,7 @@ import ImageModalContent, { ImageModalContentProps } from './ImageModalContent';
import Modal from '../Modal/Modal';
import { PbImage } from '../../generated/api-types/data-contracts';
import { ImageVariant } from '../../utils/images/imageUtils';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { resetImageModalState, setSelectedImage, setSelectedVariant } from './imageModalSlice';
interface ImageModalProps extends ImageModalContentProps {
@@ -21,7 +21,7 @@ const ImageModal: React.FC = ({
defaultVariant,
...contentProps
}) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
useEffect(() => {
if (open) {
diff --git a/frontend/components/ImageModal/ImageModalContent.tsx b/frontend/components/ImageModal/ImageModalContent.tsx
index 26526f17..b8426b33 100644
--- a/frontend/components/ImageModal/ImageModalContent.tsx
+++ b/frontend/components/ImageModal/ImageModalContent.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect } from 'react';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { AxiosResponse } from 'axios';
import { PbImage } from '../../generated/api-types/data-contracts';
import { styled } from '../../styles/stitches.config';
@@ -37,10 +37,10 @@ const ImageModalContent: React.FC = ({
uploadedImageTypeId,
isNullable = false,
}) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const [activeTab, setActiveTab] = React.useState(ImageModalTabs.Upload);
- const selectedImage = useSelector((state: ReduxState) => state.imageModal.selectedImage);
- const selectedVariant = useSelector((state: ReduxState) => state.imageModal.selectedVariant);
+ const selectedImage = useAppSelector((state: ReduxState) => state.imageModal.selectedImage);
+ const selectedVariant = useAppSelector((state: ReduxState) => state.imageModal.selectedVariant);
const [isCloudflareImage, setIsCloudflareImage] = React.useState(false);
useEffect(() => {
diff --git a/frontend/components/Layout/Layout.tsx b/frontend/components/Layout/Layout.tsx
index 9c85f62a..c3723a7e 100644
--- a/frontend/components/Layout/Layout.tsx
+++ b/frontend/components/Layout/Layout.tsx
@@ -1,4 +1,4 @@
-import React, { PropsWithChildren, useMemo } from 'react';
+import React, { PropsWithChildren, Suspense, useMemo } from 'react';
import { useAuth } from '../../hooks/useAuth';
import Header from '../../component-sections/header/Header';
import Footer from '../../component-sections/footer/Footer';
@@ -68,7 +68,11 @@ const Layout: React.FC = ({
return (
- {isLoggedIn && }
+ {isLoggedIn && (
+
+
+
+ )}
{!isLoggedIn && }
diff --git a/frontend/components/LeftNavbar/Navbar.tsx b/frontend/components/LeftNavbar/Navbar.tsx
index 2c2295ec..f2a7daaf 100644
--- a/frontend/components/LeftNavbar/Navbar.tsx
+++ b/frontend/components/LeftNavbar/Navbar.tsx
@@ -3,7 +3,7 @@ import NavbarHeader from './NavbarHeader';
import { NavbarWrapper, NavbarItem, NavbarSquare } from './navbarComponents';
import { useGetMenuItems } from '../../api/menus/useGetMenuItems';
import { useGetMenuById } from '../../api/menus/useGetMenuById';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { setMenuImage } from '../../store/globalSlice';
import { IMAGE_DEFAULT_MENU } from '../../utils/images/imageDefaultUrls';
@@ -28,7 +28,7 @@ const Navbar: React.FC = ({
postfixItemsTitle = '',
postfixItems = [],
}) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const { data: menuData } = useGetMenuById({ variables: menuId });
const { data: menuItemsData = [] } = useGetMenuItems({ variables: menuId });
diff --git a/frontend/components/LeftNavbar/NavbarHeader.tsx b/frontend/components/LeftNavbar/NavbarHeader.tsx
index 6e96777b..f2e2bd2b 100644
--- a/frontend/components/LeftNavbar/NavbarHeader.tsx
+++ b/frontend/components/LeftNavbar/NavbarHeader.tsx
@@ -1,6 +1,6 @@
import { styled } from '../../styles/stitches.config';
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../store/hooks';
import { ReduxState } from '../../store';
const NavbarHeaderComponent = styled('div', {
@@ -48,7 +48,7 @@ interface NavbarHeaderProps {
}
const NavbarHeader: React.FC = ({ title, customImage }) => {
- const image = useSelector((state: ReduxState) => state.global.menuImage);
+ const image = useAppSelector((state: ReduxState) => state.global.menuImage);
return (
diff --git a/frontend/components/UserDropdown/UserDropdown.tsx b/frontend/components/UserDropdown/UserDropdown.tsx
index 3de671d2..a1abdc1b 100644
--- a/frontend/components/UserDropdown/UserDropdown.tsx
+++ b/frontend/components/UserDropdown/UserDropdown.tsx
@@ -1,6 +1,6 @@
import React, { useCallback } from 'react';
import { useRouter } from 'next/router';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { useAuth } from '../../hooks/useAuth';
import { useLogout } from '../../api/auth/useLogout';
import { resetAuth } from '../../utils/auth/userSlice';
@@ -15,7 +15,7 @@ import { useImage } from '../../hooks/useImage';
const UserDropdown: React.FC = () => {
const router = useRouter();
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const { user } = useAuth();
const { image } = useImage(user?.imgId ?? 0);
diff --git a/frontend/hooks/useAspectRatioResizer.ts b/frontend/hooks/useAspectRatioResizer.ts
index 738838a3..e5c7f413 100644
--- a/frontend/hooks/useAspectRatioResizer.ts
+++ b/frontend/hooks/useAspectRatioResizer.ts
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState, WheelEventHandler } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { usePanning } from './usePanning';
export type AspectRatioOffset =
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
index c503e2a4..cff3c8f7 100644
--- a/frontend/hooks/useAuth.ts
+++ b/frontend/hooks/useAuth.ts
@@ -1,6 +1,6 @@
import { PbUser } from '../generated/api-types/data-contracts';
import { ReduxState } from '../store';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../store/hooks';
import { useCookies } from 'react-cookie';
import { CookieMap } from '../utils/cookies/cookieMap';
import { useEffect, useMemo } from 'react';
@@ -12,10 +12,10 @@ export type Auth = {
};
export function useAuth(): Auth {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const [cookies] = useCookies(['access_token_present']);
- const user = useSelector((state: ReduxState) => state.auth.user);
+ const user = useAppSelector((state: ReduxState) => state.auth.user);
const isLoggedIn = cookies.access_token_present ?? false;
useEffect(() => {
diff --git a/frontend/hooks/useCharacter.ts b/frontend/hooks/useCharacter.ts
index 5a868acd..c1613b5b 100644
--- a/frontend/hooks/useCharacter.ts
+++ b/frontend/hooks/useCharacter.ts
@@ -4,7 +4,7 @@ import { useGetCharacterById } from '../api/characters/useGetCharacterById';
import { TaleboundError } from '../utils/types/error';
import { useModule } from './useModule';
import { useGetModuleId } from '../api/modules/useGetModuleId';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { characterSelectors } from '../adapters/CharacterAdapter';
interface UseCharacterResponse {
@@ -19,7 +19,7 @@ export const useCharacter = (characterId: number): UseCharacterResponse => {
const { isFetching: isFetchingCharacter, error: errorCharacter } = useGetCharacterById({
variables: characterId,
});
- const character = useSelector((state) => characterSelectors.selectById(state, characterId));
+ const character = useAppSelector((state) => characterSelectors.selectById(state, characterId));
const { data: moduleId = 0 } = useGetModuleId({ variables: { characterId } });
diff --git a/frontend/hooks/useEntity.ts b/frontend/hooks/useEntity.ts
index 3c807c0d..b9263eed 100644
--- a/frontend/hooks/useEntity.ts
+++ b/frontend/hooks/useEntity.ts
@@ -2,7 +2,7 @@ import { PbViewEntity } from '../generated/api-types/data-contracts';
import { useMemo } from 'react';
import { useGetEntityById } from '../api/entities/useGetEntityById';
import { TaleboundError } from '../utils/types/error';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { entitySelectors } from '../adapters/EntityAdapter';
interface UseEntityResponse {
@@ -13,7 +13,7 @@ interface UseEntityResponse {
export const useEntity = (entityId: number): UseEntityResponse => {
const { isFetching, error } = useGetEntityById({ variables: entityId });
- const entity = useSelector((state) => entitySelectors.selectById(state, entityId));
+ const entity = useAppSelector((state) => entitySelectors.selectById(state, entityId));
return useMemo(
() => ({
diff --git a/frontend/hooks/useEntityExtended.ts b/frontend/hooks/useEntityExtended.ts
index 997177d3..0d90f170 100644
--- a/frontend/hooks/useEntityExtended.ts
+++ b/frontend/hooks/useEntityExtended.ts
@@ -14,7 +14,7 @@ import { postSelectors } from '../adapters/PostAdapter';
import { imageSelectors } from '../adapters/ImageAdapter';
import { mapSelectors } from '../adapters/MapAdapter';
import { locationSelectors } from '../adapters/LocationAdapter';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { entitySelectors } from '../adapters/EntityAdapter';
interface UseEntityResponse {
@@ -57,7 +57,7 @@ type U =
export const useEntityExtended = (entityId: number): U => {
const { isFetching, error } = useGetEntityById({ variables: entityId });
- const entity = useSelector((state) => entitySelectors.selectById(state, entityId));
+ const entity = useAppSelector((state) => entitySelectors.selectById(state, entityId));
const post = postSelectors.selectById(store.getState(), entity?.postId ?? 0);
const image = imageSelectors.selectById(store.getState(), entity?.imageId ?? 0);
diff --git a/frontend/hooks/useImage.ts b/frontend/hooks/useImage.ts
index 4b946ce0..8c246ac2 100644
--- a/frontend/hooks/useImage.ts
+++ b/frontend/hooks/useImage.ts
@@ -2,7 +2,7 @@ import { PbImage } from '../generated/api-types/data-contracts';
import { useMemo } from 'react';
import { TaleboundError } from '../utils/types/error';
import { useGetImageById } from '../api/images/useGetImageById';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { imageSelectors } from '../adapters/ImageAdapter';
interface UseImageResponse {
@@ -13,7 +13,7 @@ interface UseImageResponse {
export const useImage = (imageId: number | undefined): UseImageResponse => {
const { isFetching, error } = useGetImageById({ variables: imageId });
- const image = useSelector((state) => imageSelectors.selectById(state, imageId ?? 0));
+ const image = useAppSelector((state) => imageSelectors.selectById(state, imageId ?? 0));
return useMemo(
() => ({
diff --git a/frontend/hooks/useLocation.ts b/frontend/hooks/useLocation.ts
index 0f4c94e0..9390506c 100644
--- a/frontend/hooks/useLocation.ts
+++ b/frontend/hooks/useLocation.ts
@@ -2,7 +2,7 @@ import { PbLocation } from '../generated/api-types/data-contracts';
import { useMemo } from 'react';
import { TaleboundError } from '../utils/types/error';
import { useGetLocationById } from '../api/locations/useGetLocationById';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { locationSelectors } from '../adapters/LocationAdapter';
interface UseLocationResponse {
@@ -13,7 +13,7 @@ interface UseLocationResponse {
export const useLocation = (locationId: number | undefined): UseLocationResponse => {
const { isFetching, error } = useGetLocationById({ variables: locationId });
- const location = useSelector((state) => locationSelectors.selectById(state, locationId ?? 0));
+ const location = useAppSelector((state) => locationSelectors.selectById(state, locationId ?? 0));
return useMemo(
() => ({
diff --git a/frontend/hooks/useMap.ts b/frontend/hooks/useMap.ts
index 41152c2e..8a8e208c 100644
--- a/frontend/hooks/useMap.ts
+++ b/frontend/hooks/useMap.ts
@@ -2,7 +2,7 @@ import { PbMap } from '../generated/api-types/data-contracts';
import { useMemo } from 'react';
import { TaleboundError } from '../utils/types/error';
import { useGetMapById } from '../api/maps/useGetMapById';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { mapSelectors } from '../adapters/MapAdapter';
interface UseMapResponse {
@@ -13,7 +13,7 @@ interface UseMapResponse {
export const useMap = (mapId: number | undefined): UseMapResponse => {
const { isFetching, error } = useGetMapById({ variables: mapId });
- const map = useSelector((state) => mapSelectors.selectById(state, mapId ?? 0));
+ const map = useAppSelector((state) => mapSelectors.selectById(state, mapId ?? 0));
return useMemo(
() => ({
diff --git a/frontend/hooks/useModule.ts b/frontend/hooks/useModule.ts
index c764173d..184806e1 100644
--- a/frontend/hooks/useModule.ts
+++ b/frontend/hooks/useModule.ts
@@ -2,7 +2,7 @@ import { PbModuleType, PbViewModule } from '../generated/api-types/data-contract
import { useMemo } from 'react';
import { useGetModuleById } from '../api/modules/useGetModuleById';
import { TaleboundError } from '../utils/types/error';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { moduleSelectors } from '../adapters/ModuleAdapter';
interface UseModuleResponse {
@@ -15,7 +15,7 @@ interface UseModuleResponse {
export const useModule = (moduleId: number): UseModuleResponse => {
const { isFetching, error } = useGetModuleById({ variables: moduleId });
- const module = useSelector((state) => moduleSelectors.selectById(state, moduleId));
+ const module = useAppSelector((state) => moduleSelectors.selectById(state, moduleId));
const moduleTypeId = useMemo(() => {
if (!module?.moduleType) return undefined;
diff --git a/frontend/hooks/useModuleAdmins.ts b/frontend/hooks/useModuleAdmins.ts
index b75c922c..494fe8b1 100644
--- a/frontend/hooks/useModuleAdmins.ts
+++ b/frontend/hooks/useModuleAdmins.ts
@@ -1,6 +1,6 @@
import { useGetModuleAdmins } from '../api/modules/useGetModuleAdmins';
import { PbModuleAdmin } from '../generated/api-types/data-contracts';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { UserRole } from '../utils/auth/userUtils';
export enum ModuleAdminRole {
@@ -20,8 +20,8 @@ export function useModuleAdmins(moduleId: number): PbModuleAdmin[] {
}
export function useMyModuleRole(moduleId: number): ModuleAdminRole {
- const userId = useSelector((state) => state.auth.user?.id);
- const userRole = useSelector((state) => state.auth.role);
+ const userId = useAppSelector((state) => state.auth.user?.id);
+ const userRole = useAppSelector((state) => state.auth.role);
const moduleAdmins = useModuleAdmins(moduleId);
const myAdmin = moduleAdmins.find((moduleAdmin) => moduleAdmin.userId === userId);
diff --git a/frontend/hooks/useModuleExtended.ts b/frontend/hooks/useModuleExtended.ts
index f0c7123e..909c63d8 100644
--- a/frontend/hooks/useModuleExtended.ts
+++ b/frontend/hooks/useModuleExtended.ts
@@ -9,7 +9,7 @@ import {
import { useMemo } from 'react';
import { useGetModuleById } from '../api/modules/useGetModuleById';
import { useGetWorldById } from '../api/worlds/useGetWorldById';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { moduleSelectors } from '../adapters/ModuleAdapter';
import { useGetSystemById } from '../api/systems/useGetSystemById';
import { useGetCharacterById } from '../api/characters/useGetCharacterById';
@@ -36,7 +36,7 @@ interface UseModuleExtendedResponse {
export const useModuleExtended = (moduleId: number): UseModuleExtendedResponse => {
useGetModuleById({ variables: moduleId });
- const module = useSelector((state) => moduleSelectors.selectById(state, moduleId));
+ const module = useAppSelector((state) => moduleSelectors.selectById(state, moduleId));
const { data: world } = useGetWorldById({ variables: module?.worldId ?? 0 });
const { data: system } = useGetSystemById({ variables: module?.systemId ?? 0 });
const { data: character } = useGetCharacterById({ variables: module?.characterId ?? 0 });
diff --git a/frontend/hooks/usePost.ts b/frontend/hooks/usePost.ts
index 6314a74e..ee4cb311 100644
--- a/frontend/hooks/usePost.ts
+++ b/frontend/hooks/usePost.ts
@@ -2,7 +2,7 @@ import { PbPost } from '../generated/api-types/data-contracts';
import { useMemo } from 'react';
import { TaleboundError } from '../utils/types/error';
import { useGetPostById } from '../api/posts/useGetPostById';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { postSelectors } from '../adapters/PostAdapter';
interface UsePostResponse {
@@ -13,7 +13,7 @@ interface UsePostResponse {
export const usePost = (postId: number | undefined): UsePostResponse => {
const { isFetching, error } = useGetPostById({ variables: postId });
- const post = useSelector((state) => postSelectors.selectById(state, postId ?? 0));
+ const post = useAppSelector((state) => postSelectors.selectById(state, postId ?? 0));
return useMemo(
() => ({
diff --git a/frontend/hooks/useQuest.ts b/frontend/hooks/useQuest.ts
index 4f8074a9..1e4cdade 100644
--- a/frontend/hooks/useQuest.ts
+++ b/frontend/hooks/useQuest.ts
@@ -4,7 +4,7 @@ import { useGetQuestById } from '../api/quests/useGetQuestById';
import { TaleboundError } from '../utils/types/error';
import { useModule } from './useModule';
import { useGetModuleId } from '../api/modules/useGetModuleId';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { questSelectors } from '../adapters/QuestAdapter';
interface UseQuestResponse {
@@ -19,7 +19,7 @@ export const useQuest = (questId: number): UseQuestResponse => {
const { isFetching: isFetchingQuest, error: errorQuest } = useGetQuestById({
variables: questId,
});
- const quest = useSelector((state) => questSelectors.selectById(state, questId));
+ const quest = useAppSelector((state) => questSelectors.selectById(state, questId));
const { data: moduleId = 0 } = useGetModuleId({ variables: { questId } });
diff --git a/frontend/hooks/useSystem.ts b/frontend/hooks/useSystem.ts
index 6efb0c1c..052e15b5 100644
--- a/frontend/hooks/useSystem.ts
+++ b/frontend/hooks/useSystem.ts
@@ -4,7 +4,7 @@ import { useGetSystemById } from '../api/systems/useGetSystemById';
import { TaleboundError } from '../utils/types/error';
import { useModule } from './useModule';
import { useGetModuleId } from '../api/modules/useGetModuleId';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { systemSelectors } from '../adapters/SystemAdapter';
interface UseSystemResponse {
@@ -19,7 +19,7 @@ export const useSystem = (systemId: number): UseSystemResponse => {
const { isFetching: isFetchingSystem, error: errorSystem } = useGetSystemById({
variables: systemId,
});
- const system = useSelector((state) => systemSelectors.selectById(state, systemId));
+ const system = useAppSelector((state) => systemSelectors.selectById(state, systemId));
const { data: moduleId = 0 } = useGetModuleId({ variables: { systemId } });
diff --git a/frontend/hooks/useUserRole.ts b/frontend/hooks/useUserRole.ts
index ab68ea5b..4248c7a8 100644
--- a/frontend/hooks/useUserRole.ts
+++ b/frontend/hooks/useUserRole.ts
@@ -1,14 +1,14 @@
import { ReduxState } from '../store';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../store/hooks';
import { useEffect, useMemo } from 'react';
import { setUserRole } from '../utils/auth/userSlice';
import { useGetUserRoles } from '../api/users/useGetUserRoles';
import { isUserRole, UserRole } from '../utils/auth/userUtils';
export function useUserRole(): UserRole {
- const dispatch = useDispatch();
- const userId = useSelector((state: ReduxState) => state.auth.user?.id);
- const userRole = useSelector((state: ReduxState) => state.auth.role);
+ const dispatch = useAppDispatch();
+ const userId = useAppSelector((state: ReduxState) => state.auth.user?.id);
+ const userRole = useAppSelector((state: ReduxState) => state.auth.role);
const { data: roles } = useGetUserRoles({
variables: userId ?? 0,
diff --git a/frontend/hooks/useWorld.ts b/frontend/hooks/useWorld.ts
index 7f83524c..bd7db873 100644
--- a/frontend/hooks/useWorld.ts
+++ b/frontend/hooks/useWorld.ts
@@ -4,7 +4,7 @@ import { useGetWorldById } from '../api/worlds/useGetWorldById';
import { TaleboundError } from '../utils/types/error';
import { useModule } from './useModule';
import { useGetModuleId } from '../api/modules/useGetModuleId';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../store/hooks';
import { worldSelectors } from '../adapters/WorldAdapter';
interface UseWorldResponse {
@@ -19,7 +19,7 @@ export const useWorld = (worldId: number): UseWorldResponse => {
const { isFetching: isFetchingWorld, error: errorWorld } = useGetWorldById({
variables: worldId,
});
- const world = useSelector((state) => worldSelectors.selectById(state, worldId));
+ const world = useAppSelector((state) => worldSelectors.selectById(state, worldId));
const { data: moduleId = 0 } = useGetModuleId({ variables: { worldId } });
diff --git a/frontend/screens/menus/MenuAdministration/MenuHeaderImage.tsx b/frontend/screens/menus/MenuAdministration/MenuHeaderImage.tsx
index 533eb314..480eb28e 100644
--- a/frontend/screens/menus/MenuAdministration/MenuHeaderImage.tsx
+++ b/frontend/screens/menus/MenuAdministration/MenuHeaderImage.tsx
@@ -20,7 +20,8 @@ const MenuHeaderImage: React.FC = ({ menuId }) => {
const { mutate: updateMenu, isPending: isPending, error } = useUpdateMenu();
const changeMenuHeaderImage = useCallback(
- (image: PbImage) => {
+ (image: PbImage | undefined) => {
+ if (!image) return;
updateMenu({
menuId,
body: {
diff --git a/frontend/screens/menus/MenuCategory/CreateEntityGroupModal.tsx b/frontend/screens/menus/MenuCategory/CreateEntityGroupModal.tsx
index 2fd90efc..334d2d16 100644
--- a/frontend/screens/menus/MenuCategory/CreateEntityGroupModal.tsx
+++ b/frontend/screens/menus/MenuCategory/CreateEntityGroupModal.tsx
@@ -1,6 +1,6 @@
import React, { Suspense, useCallback, useMemo } from 'react';
import Modal from '../../../components/Modal/Modal';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import { setNewEntityGroupData } from './menuCategorySlice';
import EntityGroupForm from './EntityGroupForm/EntityGroupForm';
@@ -17,25 +17,25 @@ interface CreateEntityGroupModalProps {
}
const CreateEntityGroupModal: React.FC = ({ trigger, menuItemId }) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
- const type = useSelector((state: ReduxState) => state.menuCategory.newEntityGroupData?.type);
- const entityType = useSelector(
+ const type = useAppSelector((state: ReduxState) => state.menuCategory.newEntityGroupData?.type);
+ const entityType = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.entityType,
);
- const entityIdOfType = useSelector(
+ const entityIdOfType = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.entityIdOfType,
);
- const contentId = useSelector(
+ const contentId = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.contentId,
);
- const startEntityGroupId = useSelector(
+ const startEntityGroupId = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.startEntityGroupId,
);
- const targetEntityGroupId = useSelector(
+ const targetEntityGroupId = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.targetEntityGroupId,
);
- const targetPosition = useSelector(
+ const targetPosition = useAppSelector(
(state: ReduxState) => state.menuCategory.newEntityGroupData?.targetPosition,
);
diff --git a/frontend/screens/menus/MenuCategory/EditEntityGroupModal.tsx b/frontend/screens/menus/MenuCategory/EditEntityGroupModal.tsx
index 2ad007a3..2274e5af 100644
--- a/frontend/screens/menus/MenuCategory/EditEntityGroupModal.tsx
+++ b/frontend/screens/menus/MenuCategory/EditEntityGroupModal.tsx
@@ -1,6 +1,6 @@
import React, { Suspense, useCallback, useMemo } from 'react';
import Modal from '../../../components/Modal/Modal';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import { setEditEntityGroupId } from './menuCategorySlice';
import { EntityGroupObject } from '../../../hooks/useGetMenuItemContentHierarchy';
@@ -21,8 +21,8 @@ const EditEntityGroupModal: React.FC = ({
entityGroups,
menuItemId,
}) => {
- const dispatch = useDispatch();
- const editEntityGroupId = useSelector(
+ const dispatch = useAppDispatch();
+ const editEntityGroupId = useAppSelector(
(state: ReduxState) => state.menuCategory.editEntityGroupId,
);
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/EntityMap.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/EntityMap.tsx
index 63ef4fa5..d42c64d4 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/EntityMap.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/EntityMap.tsx
@@ -1,6 +1,6 @@
import React, { PropsWithChildren } from 'react';
import { PbEntityGroupDirection } from '../../../../generated/api-types/data-contracts';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import GenericEntityRowView from './GenericEntityRowView';
import GenericEntityAvatarView from './GenericEntityRowAvatarView';
@@ -22,7 +22,7 @@ const EntityMap: React.FC = ({
}) => {
const { map: mapData, isFetching: isPendingPost } = useMap(mapId);
const mapImageId = mapData?.thumbnailImageId ?? 0;
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
if (isPendingPost) return null;
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/EntityPost.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/EntityPost.tsx
index 542a33ee..ac36298e 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/EntityPost.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/EntityPost.tsx
@@ -1,7 +1,7 @@
import React, { PropsWithChildren } from 'react';
import { PbEntityGroupDirection } from '../../../../generated/api-types/data-contracts';
import { usePost } from '../../../../hooks/usePost';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import GenericEntityRowView from './GenericEntityRowView';
import GenericEntityAvatarView from './GenericEntityRowAvatarView';
@@ -22,7 +22,7 @@ const EntityPost: React.FC = ({
}) => {
const { post: postData, isFetching: isPendingPost } = usePost(postId);
const postImageId = postData?.imageThumbnailId ?? 0;
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
if (isPendingPost) return null;
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/EntityTableDragHandle.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/EntityTableDragHandle.tsx
index ae741db8..d401ae64 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/EntityTableDragHandle.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/EntityTableDragHandle.tsx
@@ -19,7 +19,6 @@ const EntityTableDragHandle: React.FC = ({
attributes,
listeners,
setNodeRef: setDraggableRef,
- isDragging,
} = useDraggable({
id: `${entityId}_${entityType}_new-entity`,
disabled: false,
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/GenericEntityRowAvatarView.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/GenericEntityRowAvatarView.tsx
index 7f352cad..d03789ef 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/GenericEntityRowAvatarView.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/GenericEntityRowAvatarView.tsx
@@ -41,9 +41,7 @@ const GenericEntityAvatarView: React.FC = ({
children,
contentId,
entityId,
- title,
avatarImageId,
- editModeInfo,
editMode = false,
}) => {
const { component: LinkEntity } = useLinkEntity(entityId, !editMode);
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/RemoveEntityContentButton.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/RemoveEntityContentButton.tsx
index ed0f7849..f8a0b064 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/RemoveEntityContentButton.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/RemoveEntityContentButton.tsx
@@ -1,7 +1,7 @@
import React, { useCallback } from 'react';
import { MdClose } from 'react-icons/md';
import { Button } from '../../../../components/Button/Button';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import { styled } from '../../../../styles/stitches.config';
import { useDeleteEntityGroupContent } from '../../../../api/entities/useDeleteEntityGroupContent';
@@ -28,18 +28,14 @@ interface RemoveEntityButtonProps {
}
const RemoveEntityContentButton: React.FC = ({ contentId }) => {
- const menuItemId = useSelector((state: ReduxState) => state.menuCategory.menuItemId);
+ const menuItemId = useAppSelector((state: ReduxState) => state.menuCategory.menuItemId);
const { data: menuItemContent } = useGetMenuItemContent({
variables: { menuItemId },
});
const mainEntityGroupId = menuItemContent?.mainGroupId ?? 0;
- const {
- mutate: deleteEntityGroupContent,
- isPending: isPendingDelete,
- error: errorDelete,
- } = useDeleteEntityGroupContent();
+ const { mutate: deleteEntityGroupContent } = useDeleteEntityGroupContent();
const handleRemoveEntity = useCallback(
(e: React.MouseEvent) => {
diff --git a/frontend/screens/menus/MenuCategory/EntityComponent/useLinkEntity.tsx b/frontend/screens/menus/MenuCategory/EntityComponent/useLinkEntity.tsx
index e66a335a..e93e0989 100644
--- a/frontend/screens/menus/MenuCategory/EntityComponent/useLinkEntity.tsx
+++ b/frontend/screens/menus/MenuCategory/EntityComponent/useLinkEntity.tsx
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -12,7 +12,7 @@ export interface UseLinkEntity {
const useLinkEntity = (entityId: number, validGlobal: boolean = true) => {
const router = useRouter();
- const openedUrlPrefix = useSelector((state: ReduxState) => state.menuCategory.openedUrlPrefix);
+ const openedUrlPrefix = useAppSelector((state: ReduxState) => state.menuCategory.openedUrlPrefix);
const url = `${openedUrlPrefix}/${entityId}#content`;
const LinkEntity = useMemo(() => {
diff --git a/frontend/screens/menus/MenuCategory/MenuCategory.tsx b/frontend/screens/menus/MenuCategory/MenuCategory.tsx
index 829b5bad..6ee571d4 100644
--- a/frontend/screens/menus/MenuCategory/MenuCategory.tsx
+++ b/frontend/screens/menus/MenuCategory/MenuCategory.tsx
@@ -7,7 +7,7 @@ import LoadingText from '../../../components/Loading/LoadingText';
import ErrorText from '../../../components/ErrorText/ErrorText';
import MenuCategoryContent from './MenuCategoryContent';
import { DndContext, DragEndEvent, DragStartEvent, pointerWithin } from '@dnd-kit/core';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import {
MenuCategoryDraggingData,
@@ -41,8 +41,8 @@ const MenuCategory: React.FC = ({
worldImageThumbnail,
canEdit = false,
}) => {
- const dispatch = useDispatch();
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
+ const dispatch = useAppDispatch();
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
const { data: menuItemsData = [] } = useGetMenuItems({ variables: menuId });
const {
diff --git a/frontend/screens/menus/MenuCategory/MenuCategoryContent.tsx b/frontend/screens/menus/MenuCategory/MenuCategoryContent.tsx
index e71bf611..cb8a5ffc 100644
--- a/frontend/screens/menus/MenuCategory/MenuCategoryContent.tsx
+++ b/frontend/screens/menus/MenuCategory/MenuCategoryContent.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import MenuItemContentElement from './MenuItemContentElement';
import { DragOverlay } from '@dnd-kit/core';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import MenuItemContentElementEntityGroup from './MenuItemContentElementEntityGroup';
import { useGetMenuItemContentHierarchy } from '../../../hooks/useGetMenuItemContentHierarchy';
@@ -18,8 +18,8 @@ const MenuCategoryContent: React.FC = ({
menuItemId,
isPending = false,
}) => {
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
- const draggingData = useSelector((state: ReduxState) => state.menuCategory.draggingData);
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
+ const draggingData = useAppSelector((state: ReduxState) => state.menuCategory.draggingData);
const menuItemContent = useGetMenuItemContentHierarchy(menuItemId);
console.log('MENU ITEM CONTENT: ', menuItemContent);
diff --git a/frontend/screens/menus/MenuCategory/MenuItemContentElementEntity.tsx b/frontend/screens/menus/MenuCategory/MenuItemContentElementEntity.tsx
index 505ee5b7..df201f67 100644
--- a/frontend/screens/menus/MenuCategory/MenuItemContentElementEntity.tsx
+++ b/frontend/screens/menus/MenuCategory/MenuItemContentElementEntity.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { MdDragIndicator } from 'react-icons/md';
@@ -26,7 +26,7 @@ const MenuItemContentElementEntity: React.FC
showHandles,
parentGroup,
}) => {
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
const groupStyle = parentGroup?.style ?? PbEntityGroupStyle.ENTITY_GROUP_STYLE_FRAMED;
const groupDirection =
diff --git a/frontend/screens/menus/MenuCategory/MenuItemContentElementEntityGroup.tsx b/frontend/screens/menus/MenuCategory/MenuItemContentElementEntityGroup.tsx
index fd45ff15..d940ff39 100644
--- a/frontend/screens/menus/MenuCategory/MenuItemContentElementEntityGroup.tsx
+++ b/frontend/screens/menus/MenuCategory/MenuItemContentElementEntityGroup.tsx
@@ -1,7 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import ContentSection from '../../../components/ContentSection/ContentSection';
import MenuItemContentElement from './MenuItemContentElement';
-import { useDispatch, useSelector } from 'react-redux';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { ReduxState } from '../../../store';
import { useDraggable, useDroppable } from '@dnd-kit/core';
import { DragHandle } from '../MenuAdministration/menuAdministrationComponents';
@@ -39,9 +39,9 @@ const MenuItemContentElementEntityGroup: React.FC {
- const dispatch = useDispatch();
- const editMode = useSelector((state: ReduxState) => state.menuCategory.editMode);
- const menuItemId = useSelector((state: ReduxState) => state.menuCategory.menuItemId);
+ const dispatch = useAppDispatch();
+ const editMode = useAppSelector((state: ReduxState) => state.menuCategory.editMode);
+ const menuItemId = useAppSelector((state: ReduxState) => state.menuCategory.menuItemId);
const {
attributes,
diff --git a/frontend/screens/modules/Edit/ModuleImages.tsx b/frontend/screens/modules/Edit/ModuleImages.tsx
index f9b03f76..8a1d7b6b 100644
--- a/frontend/screens/modules/Edit/ModuleImages.tsx
+++ b/frontend/screens/modules/Edit/ModuleImages.tsx
@@ -75,7 +75,8 @@ const ModuleImages: React.FC = ({ moduleId, disabled }) => {
const updateModuleMutation = useUpdateModule();
const changeModuleImage = useCallback(
- (image: PbImage) => {
+ (image: PbImage | undefined) => {
+ if (!image) return;
updateModuleMutation.mutate({
moduleId: moduleId,
body: {
diff --git a/frontend/screens/modules/ModuleCollaborators/CollaboratorsApproved/CollaboratorRowApproved.tsx b/frontend/screens/modules/ModuleCollaborators/CollaboratorsApproved/CollaboratorRowApproved.tsx
index 6b36ca1f..80a2d6c6 100644
--- a/frontend/screens/modules/ModuleCollaborators/CollaboratorsApproved/CollaboratorRowApproved.tsx
+++ b/frontend/screens/modules/ModuleCollaborators/CollaboratorsApproved/CollaboratorRowApproved.tsx
@@ -13,7 +13,7 @@ import { useUpdateModuleAdmin } from '../../../../api/modules/useUpdateModuleAdm
import { useDeleteModuleAdmin } from '../../../../api/modules/useDeleteModuleAdmin';
import AlertDialog from '../../../../components/AlertDialog/AlertDialog';
import ErrorText from '../../../../components/ErrorText/ErrorText';
-import { useSelector } from 'react-redux';
+import { useAppSelector } from '../../../../store/hooks';
import { ReduxState } from '../../../../store';
import { useImage } from '../../../../hooks/useImage';
import { useModule } from '../../../../hooks/useModule';
@@ -34,7 +34,7 @@ const CollaboratorRowApproved: React.FC = ({
const moduleId = data.moduleId ?? 0;
const { module } = useModule(moduleId);
const role = useMyModuleRole(moduleId);
- const userId = useSelector((state: ReduxState) => state.auth.user?.id);
+ const userId = useAppSelector((state: ReduxState) => state.auth.user?.id);
const isMyRow = data.user?.id === userId;
const profileLink = `/user/${data.user?.id}/profile`;
const { image: imageAvatar } = useImage(data?.user?.imgId ?? 0);
diff --git a/frontend/screens/modules/ModuleOpened.tsx b/frontend/screens/modules/ModuleOpened.tsx
index d28f8535..b682e9a4 100644
--- a/frontend/screens/modules/ModuleOpened.tsx
+++ b/frontend/screens/modules/ModuleOpened.tsx
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
-import { useDispatch } from 'react-redux';
+import { useAppDispatch } from '../../store/hooks';
import { setHeaderImage } from '../../store/globalSlice';
import { imageModifyVariant, ImageVariant } from '../../utils/images/imageUtils';
import { useImage } from '../../hooks/useImage';
@@ -10,7 +10,7 @@ interface ModuleOpenedProps {
}
const ModuleOpened: React.FC = ({ moduleId }) => {
- const dispatch = useDispatch();
+ const dispatch = useAppDispatch();
const { module } = useModule(moduleId);
const { image: headerImg } = useImage(module?.headerImgId);
diff --git a/frontend/screens/quests/CreateQuest/CreateQuest.tsx b/frontend/screens/quests/CreateQuest/CreateQuest.tsx
index 1ffebc16..9ed6d188 100644
--- a/frontend/screens/quests/CreateQuest/CreateQuest.tsx
+++ b/frontend/screens/quests/CreateQuest/CreateQuest.tsx
@@ -7,7 +7,6 @@ import ContentSection from '../../../components/ContentSection/ContentSection';
import { useInput } from '../../../hooks/useInput';
import { HelperMessage, HelperType } from '../../../utils/form/helperTypes';
import { validateString } from '../../../utils/form/validatePassword';
-import { styled } from '../../../styles/stitches.config';
import ImageCard from '../../../components/ImageCard/ImageCard';
import { TitleH2 } from '../../../components/Typography/Title';
import { Text } from '../../../components/Typography/Text';
@@ -25,19 +24,6 @@ import SystemCard from '../../../components/SystemCard/SystemCard';
import { getQuestStatSections } from '../../../components/QuestCard/QuestCard';
import LoadingText from '../../../components/Loading/LoadingText';
-const InputDescription = styled('div', {
- borderRadius: '$md',
- padding: '$md',
- display: 'flex',
- flexDirection: 'column',
- fontSize: '$sm',
-
- 'div li': {
- listStyleType: 'none',
- paddingLeft: '$md',
- },
-});
-
const CreateQuest: React.FC = () => {
const createQuestMutation = useCreateQuest();
const [worldSelectorOpen, setWorldSelectorOpen] = useState(false);
diff --git a/frontend/store/hooks.ts b/frontend/store/hooks.ts
new file mode 100644
index 00000000..c1f20b4b
--- /dev/null
+++ b/frontend/store/hooks.ts
@@ -0,0 +1,5 @@
+import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux';
+import type { ReduxDispatch, ReduxState } from './index';
+
+export const useAppDispatch: () => ReduxDispatch = useDispatch;
+export const useAppSelector: TypedUseSelectorHook = useSelector;
diff --git a/frontend/utils/types/redux-overrides.d.ts b/frontend/utils/types/redux-overrides.d.ts
deleted file mode 100644
index 8e3fc8bf..00000000
--- a/frontend/utils/types/redux-overrides.d.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-// see https://github.com/reduxjs/react-redux/issues/1879#issuecomment-1104933754
-declare module 'react-redux' {
- import { ReduxDispatch, ReduxState } from '../../store';
- import { EqualityFn } from 'reselect';
-
- export declare const useSelector: (
- selector: (state: ReduxState) => T,
- equalityFn?: EqualityFn,
- ) => T;
-
- export declare const useDispatch: () => ReduxDispatch;
-}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index baecab01..eed1bbce 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -7208,18 +7208,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/react@npm:*, @types/react@npm:>=16":
- version: 18.2.20
- resolution: "@types/react@npm:18.2.20"
- dependencies:
- "@types/prop-types": "*"
- "@types/scheduler": "*"
- csstype: ^3.0.2
- checksum: 30f699c60e5e4bfef273ce64d320651cdd60f5c6a08361c6c7eca8cebcccda1ac953d2ee57c9f321b5ae87f8a62c72b6d35ca42df0e261d337849952daab2141
- languageName: node
- linkType: hard
-
-"@types/react@npm:^18.2.31":
+"@types/react@npm:*, @types/react@npm:>=16, @types/react@npm:^18.2.31":
version: 18.2.31
resolution: "@types/react@npm:18.2.31"
dependencies:
diff --git a/scripts/worktree-dev/README.md b/scripts/worktree-dev/README.md
new file mode 100644
index 00000000..098424df
--- /dev/null
+++ b/scripts/worktree-dev/README.md
@@ -0,0 +1,195 @@
+# Talebound worktree development
+
+This Linux/WSL workflow follows the lifecycle conventions in the
+[SWUBASE agent guide](https://github.com/the-medo/swu-collection/blob/main/AGENTS.md),
+adapted for Talebound's Go/Next.js applications. It requires Bash, Python 3.9+,
+Git, and a local Docker Engine with Compose v2. Remote Docker contexts and native
+macOS/Windows Docker Desktop are not supported by the loopback-address scheme.
+
+## Start a worktree
+
+Keep the primary checkout at `/home/medo/dev/talebound/monorepo` and worktrees
+under `/home/medo/dev/talebound/worktrees/`. From a task worktree:
+
+```sh
+scripts/worktree-dev/bootstrap-worktree.sh
+scripts/worktree-dev/talebound-worktree-dev up
+scripts/worktree-dev/talebound-worktree-dev status
+```
+
+The bootstrap delegates to `setup`. It creates fresh local configuration,
+starts PostgreSQL/Redis/Mailpit, installs Yarn dependencies with `--immutable`,
+and applies migrations. It does not start the frontend or backend. `up` also
+runs setup, then starts both applications and waits for their health checks.
+The first run downloads container images/dependencies and compiles Go.
+
+`setup`/`up` preserve existing data, credentials, and allocated addresses. There
+is no production database import or automatic dump download. A new database
+contains the schema and reference data supplied by the migrations. Register
+your own local account and open its verification email in Mailpit. Password-reset
+emails go to that same inbox. No AWS or email credentials are needed.
+
+## Isolation and addresses
+
+Each checkout gets a path-derived Compose identity and its own:
+
+- PostgreSQL 15 container and persistent database volume;
+- Redis 7 instance and persistent email queue;
+- Mailpit inbox with persistent captured messages;
+- Go module/build cache volumes and frontend dependency/build directories;
+- random database password and 32-character token key;
+- loopback IP in `127.64.0.0/16`, plus four reserved ports in `20000–44999`.
+
+The app, API, database, and inbox bind only to the allocated loopback IP. Redis,
+SMTP, and gRPC have no published host ports. Host-only auth cookies stay on that
+worktree's IP, and backend CORS permits its exact frontend origin. Cookies do
+not distinguish ports, which is why the address also differs between worktrees.
+No DNS service or hosts-file change is needed on Linux.
+
+Always use the URLs printed by `status`; do not replace the address with
+`localhost`. For example, a worktree might use `http://127.64.20.30:20000`.
+These addresses refer to the development machine. Access from a different
+machine requires explicit forwarding; this tool does not set up Tailscale or
+HTTPS. If a local proxy intercepts loopback traffic, bypass it for `127.0.0.0/8`.
+
+Address reservations are serialized in `/talebound-dev/`, and
+lifecycle operations on the same worktree are locked. Stopped worktrees keep
+their reservations. If an unrelated process later occupies an allocated port,
+startup fails without killing it or silently changing the worktree URL.
+
+## Commands
+
+```sh
+scripts/worktree-dev/talebound-worktree-dev setup
+scripts/worktree-dev/talebound-worktree-dev up
+scripts/worktree-dev/talebound-worktree-dev status
+scripts/worktree-dev/talebound-worktree-dev status --json
+scripts/worktree-dev/talebound-worktree-dev logs backend
+scripts/worktree-dev/talebound-worktree-dev restart backend
+scripts/worktree-dev/talebound-worktree-dev migrate
+scripts/worktree-dev/talebound-worktree-dev test-backend
+scripts/worktree-dev/talebound-worktree-dev down
+```
+
+`setup --services-only` starts just PostgreSQL, Redis, and Mailpit; it skips
+dependency installation and migrations. It is useful for infrastructure tests.
+`logs` shows the last 100 log lines; it accepts any service name or no argument
+for all services. `restart frontend` applies changed frontend configuration.
+
+The frontend uses a live source mount and hot reload. The backend copies source
+into its container at startup because the existing migrator generates SQL files.
+Use `restart backend` after backend source changes. Its entrypoint compiles the
+binary and uses `exec` so Docker can terminate the actual application process.
+
+## Local environment files
+
+| Path | Purpose |
+| --- | --- |
+| `.talebound/state.json` | Worktree identity, addresses, credentials |
+| `.env.worktree` | Generated Compose settings |
+| `backend/app.env.worktree` | Generated backend runtime overrides |
+| `frontend/.env.worktree` | Generated frontend runtime settings |
+| `.talebound/database.env` | Host connection settings for psql/IDE tools |
+| `.talebound/integrations.env` | Optional developer-supplied image settings |
+
+All these files are ignored and created with mode `0600`; `.talebound/` is
+`0700`. Generated files are refreshed by `setup`/`up` without rotating credentials.
+Do not copy state or generated env files between worktrees, manually edit generated
+settings, or move an initialized worktree. Purge its stack before moving/removing
+it, then bootstrap it at the new path.
+
+The backend snapshot replaces `app.env.local` with the generated worktree
+overrides and also receives them as process environment. The frontend receives
+its generated settings as process environment, taking precedence over Next.js
+dotenv files. Existing `backend/app.env.local` and `frontend/.env.local` are
+left alone and are not copied into a newly created worktree. The launcher
+explicitly sets every currently used frontend integration variable.
+
+Optional image integrations can be configured in `.talebound/integrations.env`:
+
+```dotenv
+CLOUDFLARE_API_TOKEN=development-token
+CLOUDFLARE_ACCOUNT_ID=development-account-id
+NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=development-delivery-hash
+NEXT_PUBLIC_CLOUDFLARE_HOSTNAME=imagedelivery.net
+```
+
+Only these keys plus `NEXT_PUBLIC_RECAPTCHA_SITE_KEY` are accepted. Values are
+literal strings, not shell expressions. Use development-only credentials;
+uploads with configured credentials contact Cloudflare. The default reCAPTCHA
+key is Google's public development test key. Sentry is disabled. SMTP always
+targets this stack's Mailpit; the backend now honors `SMTP_ENDPOINT` (hostname
+or `host:port`), while the existing deployed configuration still specifies SES.
+
+## Database and development checks
+
+To run SQL without exposing a password in a command:
+
+```sh
+printf 'SELECT version, dirty FROM schema_migrations;\n' | \
+ scripts/worktree-dev/talebound-worktree-dev db
+```
+
+For an IDE connection, use the host/port and credentials in
+`.talebound/database.env` with database `talebound`, user `root`, and SSL disabled.
+The existing function migrations require the `root` ownership role.
+Backend short tests write fixtures to this worktree's database and skip the
+legacy test that sends a real email.
+
+For containerized frontend lint/build/API generation after setup:
+
+```sh
+# Obtain the project name without printing credentials.
+project=$(python3 -c 'import json; print(json.load(open(".talebound/state.json"))["project"])')
+docker compose -p "$project" --env-file .env.worktree \
+ -f scripts/worktree-dev/compose.yaml run --rm --no-deps frontend \
+ node .yarn/releases/yarn-3.6.1.cjs lint
+```
+
+Replace `lint` with `build` or `generate`. Stop the frontend before `build`
+because Next.js uses the same `.next` directory for development and production
+builds. Both lint and type checking must stay enabled.
+For backend compilation using the same `project` variable:
+
+```sh
+docker compose -p "$project" --env-file .env.worktree \
+ -f scripts/worktree-dev/compose.yaml run --rm --no-deps backend go build ./...
+```
+
+## Cleanup
+
+`down` stops only this worktree's containers and retains data/caches/settings.
+To intentionally discard the worktree database, email queue, inbox, and caches:
+
+```sh
+scripts/worktree-dev/talebound-worktree-dev status
+scripts/worktree-dev/talebound-worktree-dev down --purge-data
+```
+
+Purge checks ownership, removes this Compose project's containers/network/volumes,
+and releases its generated configuration and reservations. Optional integration
+settings and frontend dependencies remain in the worktree. Every managed Docker
+resource carries `com.talebound.managed`, `com.talebound.worktree`, and
+`com.talebound.identity` labels. Operations refuse resources with mismatched or
+missing ownership labels, including resources with only a matching Compose
+project label. No global Docker prune command is used.
+
+Purge before `git worktree remove` when the data is disposable. Keep the worktree
+and its state if its database is still needed. There is no automatic stale
+worktree cleanup, database reset, or restore operation.
+
+## Lifecycle validation
+
+```sh
+bash -n scripts/worktree-dev/*.sh scripts/worktree-dev/talebound-worktree-dev
+python3 -m py_compile scripts/worktree-dev/*.py
+scripts/worktree-dev/test-concurrent-worktrees.sh
+```
+
+The integration test creates two disposable Git worktrees under this workspace,
+copies the current tooling into them, and starts real data services concurrently.
+It checks isolated databases/Redis/addresses/keys, private ignored configuration,
+idempotent setup, persistence after stopping, rejection of a foreign-labelled
+resource, and that purging one stack leaves the other usable. It removes only
+its test resources and worktrees. It needs Docker and may pull the three data
+service images; it does not install frontend dependencies or contact production.
diff --git a/scripts/worktree-dev/backend-entrypoint.sh b/scripts/worktree-dev/backend-entrypoint.sh
new file mode 100755
index 00000000..5eb38fb4
--- /dev/null
+++ b/scripts/worktree-dev/backend-entrypoint.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+# The migrator writes generated SQL files. Keep these out of the source tree.
+mkdir -p /tmp/backend
+cp -a /source/. /tmp/backend/
+cp /runtime/backend.env /tmp/backend/app.env.local
+cd /tmp/backend
+case "${1:-serve}" in
+ migrate)
+ exec go run github.com/the-medo/golang-migrate-objects \
+ -mpath=file://db/migration -obj_path=file://db/migration/objects \
+ -db_source="$DB_SOURCE" -co_filename=migrations_create_objects.sql \
+ -do_filename=migrations_drop_objects.sql -up
+ ;;
+ test) exec go test -short ./... ;;
+ serve)
+ go build -o /tmp/talebound .
+ exec /tmp/talebound
+ ;;
+ *) exec "$@" ;;
+esac
diff --git a/scripts/worktree-dev/bootstrap-worktree.sh b/scripts/worktree-dev/bootstrap-worktree.sh
new file mode 100755
index 00000000..41c6e3bf
--- /dev/null
+++ b/scripts/worktree-dev/bootstrap-worktree.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+exec "$SCRIPT_DIR/talebound-worktree-dev" setup "$@"
diff --git a/scripts/worktree-dev/compose.yaml b/scripts/worktree-dev/compose.yaml
new file mode 100644
index 00000000..9b7a87d5
--- /dev/null
+++ b/scripts/worktree-dev/compose.yaml
@@ -0,0 +1,122 @@
+x-labels: &labels
+ com.talebound.managed: worktree-dev
+ com.talebound.worktree: ${TB_ROOT:?Run setup first}
+ com.talebound.identity: ${TB_PROJECT:?Run setup first}
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ labels: *labels
+ environment:
+ POSTGRES_USER: root
+ POSTGRES_PASSWORD: ${TB_DB_PASSWORD:?Run setup first}
+ POSTGRES_DB: talebound
+ ports:
+ - '${TB_HOST}:${TB_DB_PORT}:5432'
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U root -d talebound']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+
+ redis:
+ image: redis:7-alpine
+ labels: *labels
+ command: ['redis-server', '--appendonly', 'yes']
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ['CMD', 'redis-cli', 'ping']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+
+ mailpit:
+ image: axllent/mailpit:v1.27.4
+ labels: *labels
+ environment:
+ MP_DATABASE: /data/mailpit.db
+ ports:
+ - '${TB_HOST}:${TB_MAIL_PORT}:8025'
+ volumes:
+ - mail-data:/data
+ healthcheck:
+ test: ['CMD', '/mailpit', 'readyz']
+ interval: 2s
+ timeout: 3s
+ retries: 30
+
+ backend:
+ image: golang:1.23-bookworm
+ labels: *labels
+ init: true
+ entrypoint: ['bash', '/tooling/backend-entrypoint.sh']
+ command: ['serve']
+ env_file:
+ - ${TB_ROOT}/backend/app.env.worktree
+ ports:
+ - '${TB_HOST}:${TB_API_PORT}:8080'
+ volumes:
+ - ${TB_ROOT}/backend:/source:ro
+ - ${TB_ROOT}/backend/app.env.worktree:/runtime/backend.env:ro
+ - ${TB_ROOT}/scripts/worktree-dev:/tooling:ro
+ - go-modules:/go
+ - go-build:/root/.cache/go-build
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ mailpit:
+ condition: service_healthy
+ healthcheck:
+ test: ['CMD-SHELL', 'curl -fsS http://127.0.0.1:8080/swagger/talebound.swagger.json >/dev/null']
+ interval: 5s
+ timeout: 3s
+ start_period: 120s
+ retries: 60
+
+ frontend:
+ image: node:22-bookworm-slim
+ labels: *labels
+ init: true
+ user: '${TB_UID}:${TB_GID}'
+ working_dir: /app
+ command: ['node', '.yarn/releases/yarn-3.6.1.cjs', 'dev', '--hostname', '0.0.0.0']
+ environment:
+ NEXT_TELEMETRY_DISABLED: '1'
+ YARN_CACHE_FOLDER: /app/.yarn/cache
+ YARN_GLOBAL_FOLDER: /tmp/yarn-global
+ env_file:
+ - ${TB_ROOT}/frontend/.env.worktree
+ ports:
+ - '${TB_HOST}:${TB_WEB_PORT}:4000'
+ volumes:
+ - ${TB_ROOT}/frontend:/app
+ depends_on:
+ backend:
+ condition: service_healthy
+ healthcheck:
+ test: ['CMD', 'node', '-e', 'fetch("http://127.0.0.1:4000").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))']
+ interval: 10s
+ timeout: 10s
+ start_period: 90s
+ retries: 30
+
+networks:
+ default:
+ labels: *labels
+
+volumes:
+ postgres-data:
+ labels: *labels
+ redis-data:
+ labels: *labels
+ mail-data:
+ labels: *labels
+ go-modules:
+ labels: *labels
+ go-build:
+ labels: *labels
diff --git a/scripts/worktree-dev/talebound-worktree-dev b/scripts/worktree-dev/talebound-worktree-dev
new file mode 100755
index 00000000..b136e8cd
--- /dev/null
+++ b/scripts/worktree-dev/talebound-worktree-dev
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+exec python3 "$SCRIPT_DIR/worktree_dev.py" "$@"
diff --git a/scripts/worktree-dev/test-concurrent-worktrees.sh b/scripts/worktree-dev/test-concurrent-worktrees.sh
new file mode 100755
index 00000000..56240685
--- /dev/null
+++ b/scripts/worktree-dev/test-concurrent-worktrees.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -Eeuo pipefail
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
+exec python3 "$SCRIPT_DIR/test_concurrent_worktrees.py"
diff --git a/scripts/worktree-dev/test_concurrent_worktrees.py b/scripts/worktree-dev/test_concurrent_worktrees.py
new file mode 100644
index 00000000..56f813d4
--- /dev/null
+++ b/scripts/worktree-dev/test_concurrent_worktrees.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Real Docker integration test: isolation, repeatability, ownership and cleanup."""
+import concurrent.futures
+import hashlib
+import json
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+
+ROOT = Path(__file__).resolve().parents[2]
+
+
+def run(*args, cwd=ROOT, **kwargs):
+ return subprocess.run(args, cwd=cwd, text=True, check=True,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs).stdout
+
+
+def cli(root, *args, **kwargs):
+ return run(str(root / 'scripts/worktree-dev/talebound-worktree-dev'), *args, cwd=root, **kwargs)
+
+
+def sql(root, statement):
+ return cli(root, 'db', input=statement + '\n')
+
+
+def main():
+ common = Path(run('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip())
+ parent = common.parent.parent / 'worktrees'
+ parent.mkdir(exist_ok=True)
+ temporary = Path(tempfile.mkdtemp(prefix='verify-worktree-dev-', dir=parent))
+ roots = [temporary / 'a', temporary / 'b']
+ foreign = None
+ try:
+ for root in roots:
+ run('git', 'worktree', 'add', '--detach', str(root), 'HEAD')
+ shutil.copytree(ROOT / 'scripts/worktree-dev', root / 'scripts/worktree-dev', dirs_exist_ok=True)
+ shutil.copyfile(ROOT / '.gitignore', root / '.gitignore')
+ with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
+ list(pool.map(lambda root: cli(root, 'setup', '--services-only'), roots))
+ states = [json.loads((root / '.talebound/state.json').read_text()) for root in roots]
+ a, b = states
+ assert a['project'] != b['project'] and a['host'] != b['host']
+ assert not set(a['ports'].values()) & set(b['ports'].values())
+ assert a['db_password'] != b['db_password'] and a['token_key'] != b['token_key']
+ for root, state in zip(roots, states):
+ cli(root, 'status')
+ public = cli(root, 'status', '--json')
+ assert state['db_password'] not in public and state['token_key'] not in public
+ for relative in ['.env.worktree', 'backend/app.env.worktree', 'frontend/.env.worktree', '.talebound/state.json']:
+ assert (root / relative).stat().st_mode & 0o777 == 0o600
+ run('git', 'check-ignore', '--quiet', relative, cwd=root)
+ containers = run('docker', 'ps', '-q', '--filter', f"label=com.talebound.identity={state['project']}").split()
+ assert len(containers) == 3
+ for container in containers:
+ obj = json.loads(run('docker', 'inspect', container))[0]
+ assert obj['Config']['Labels']['com.talebound.worktree'] == str(root)
+ for mappings in obj['NetworkSettings']['Ports'].values():
+ for binding in mappings or []:
+ assert binding['HostIp'] == state['host']
+ sql(roots[0], 'CREATE TABLE isolation_marker(value text); INSERT INTO isolation_marker VALUES (\'only-a\');')
+ assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;')
+ assert 'missing' in sql(roots[1], "SELECT coalesce(to_regclass('isolation_marker')::text, 'missing');")
+ run('docker', 'exec', f"{a['project']}-redis-1", 'redis-cli', 'set', 'isolation', 'only-a')
+ assert run('docker', 'exec', f"{b['project']}-redis-1", 'redis-cli', 'get', 'isolation').strip() == ''
+ env_before = {rel: hashlib.sha256((roots[0] / rel).read_bytes()).hexdigest()
+ for rel in ['.env.worktree', 'backend/app.env.worktree', 'frontend/.env.worktree']}
+ cli(roots[0], 'setup', '--services-only')
+ assert json.loads((roots[0] / '.talebound/state.json').read_text()) == a
+ assert all(hashlib.sha256((roots[0] / rel).read_bytes()).hexdigest() == value for rel, value in env_before.items())
+ assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;')
+ cli(roots[0], 'down')
+ assert 'ready' in sql(roots[1], "SELECT 'ready';")
+ cli(roots[0], 'setup', '--services-only')
+ assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;')
+ # A foreign volume sharing the Compose project must block destructive cleanup.
+ foreign = f"{a['project']}_foreign-test"
+ run('docker', 'volume', 'create', '--label', f"com.docker.compose.project={a['project']}", foreign)
+ refused = subprocess.run([str(roots[0] / 'scripts/worktree-dev/talebound-worktree-dev'), 'down', '--purge-data'],
+ text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
+ assert refused.returncode != 0 and 'ownership labels do not match' in refused.stdout
+ assert 'only-a' in run('docker', 'exec', f"{a['project']}-postgres-1", 'psql',
+ '-U', 'root', '-d', 'talebound', '-c', 'TABLE isolation_marker;')
+ run('docker', 'volume', 'rm', foreign)
+ foreign = None
+ cli(roots[0], 'down', '--purge-data')
+ assert not run('docker', 'volume', 'ls', '-q', '--filter', f"label=com.talebound.identity={a['project']}").strip()
+ assert 'ready' in sql(roots[1], "SELECT 'ready';")
+ print('PASS: concurrent setup, distinct DB/Redis/hosts/ports/keys, private ignored env files, repeatability, stop/restart persistence, ownership refusal, and scoped purge.')
+ finally:
+ if foreign:
+ run('docker', 'volume', 'rm', foreign)
+ for root in roots:
+ if (root / '.talebound/state.json').exists():
+ cli(root, 'down', '--purge-data')
+ if root.exists():
+ # Only these newly-created disposable checkouts are removed.
+ run('git', 'worktree', 'remove', '--force', str(root))
+ temporary.rmdir()
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except subprocess.CalledProcessError as error:
+ print(error.stdout)
+ raise
diff --git a/scripts/worktree-dev/worktree_dev.py b/scripts/worktree-dev/worktree_dev.py
new file mode 100644
index 00000000..2d86a0d4
--- /dev/null
+++ b/scripts/worktree-dev/worktree_dev.py
@@ -0,0 +1,319 @@
+#!/usr/bin/env python3
+"""Linux/WSL worktree lifecycle; requires only Python's standard library and Docker."""
+
+import argparse
+import contextlib
+import fcntl
+import hashlib
+import json
+import os
+from pathlib import Path
+import secrets
+import socket
+import subprocess
+import sys
+
+ROOT = Path(__file__).resolve().parents[2]
+LOCAL = ROOT / '.talebound'
+STATE = LOCAL / 'state.json'
+SERVICES = ('postgres', 'redis', 'mailpit', 'backend', 'frontend')
+VOLUMES = ('postgres-data', 'redis-data', 'mail-data', 'go-modules', 'go-build')
+EXTERNAL_KEYS = {
+ 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID',
+ 'NEXT_PUBLIC_CLOUDFLARE_HOSTNAME', 'NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH',
+ 'NEXT_PUBLIC_RECAPTCHA_SITE_KEY',
+}
+
+
+def run(*args, capture=False, **kwargs):
+ return subprocess.run(args, check=True, text=True, cwd=ROOT,
+ stdout=subprocess.PIPE if capture else None, **kwargs)
+
+
+def git(*args):
+ return run('git', *args, capture=True).stdout.strip()
+
+
+def atomic_write(path, content):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp = path.with_name(path.name + '.tmp')
+ with temp.open('w') as handle:
+ os.chmod(temp, 0o600)
+ handle.write(content)
+ temp.replace(path)
+
+
+@contextlib.contextmanager
+def locked(path):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open('a') as handle:
+ os.chmod(path, 0o600)
+ fcntl.flock(handle, fcntl.LOCK_EX)
+ yield
+
+
+def identity():
+ return 'talebound-' + hashlib.sha256(str(ROOT).encode()).hexdigest()[:12]
+
+
+def load_state():
+ state = json.loads(STATE.read_text())
+ if state.get('version') != 1 or state.get('root') != str(ROOT) or state.get('project') != identity():
+ raise RuntimeError('Local state belongs to another path/version. Never copy .talebound between worktrees.')
+ if not state['host'].startswith('127.64.'):
+ raise RuntimeError('Worktree services must bind to their allocated loopback address.')
+ return state
+
+
+def port_available(host, port):
+ with socket.socket() as sock:
+ try:
+ sock.bind((host, port))
+ return True
+ except OSError:
+ return False
+
+
+def create_state():
+ # Reserve addresses and ports even while stopped; serialize simultaneous setups.
+ registry_dir = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir')) / 'talebound-dev'
+ with locked(registry_dir / 'registry.lock'):
+ registry_path = registry_dir / 'registry.json'
+ registry = json.loads(registry_path.read_text()) if registry_path.exists() else {}
+ if STATE.exists():
+ return load_state()
+ if str(ROOT) in registry:
+ raise RuntimeError('State is missing for a registered worktree. Restore .talebound/state.json before proceeding.')
+ used_hosts = {item['host'] for item in registry.values()}
+ used_ports = {port for item in registry.values() for port in item['ports'].values()}
+ seed = int(hashlib.sha256(str(ROOT).encode()).hexdigest()[:8], 16)
+ for offset in range(65534):
+ address = (seed + offset) % 65534 + 1
+ host = f'127.64.{address // 256}.{address % 256}'
+ if host not in used_hosts:
+ break
+ else:
+ raise RuntimeError('No free worktree loopback addresses.')
+ ports = {}
+ for name in ('web', 'api', 'db', 'mail'):
+ for port in range(20000, 45000):
+ if port not in used_ports and port_available('127.0.0.1', port) and port_available(host, port):
+ ports[name] = port
+ used_ports.add(port)
+ break
+ else:
+ raise RuntimeError('No free worktree ports.')
+ state = dict(version=1, root=str(ROOT), project=identity(), host=host, ports=ports,
+ db_password=secrets.token_hex(20), token_key=secrets.token_hex(16))
+ atomic_write(STATE, json.dumps(state, indent=2) + '\n')
+ registry[str(ROOT)] = dict(host=host, ports=ports)
+ atomic_write(registry_path, json.dumps(registry, indent=2) + '\n')
+ return state
+
+
+def forget_state():
+ registry_dir = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir')) / 'talebound-dev'
+ with locked(registry_dir / 'registry.lock'):
+ registry_path = registry_dir / 'registry.json'
+ registry = json.loads(registry_path.read_text()) if registry_path.exists() else {}
+ registry.pop(str(ROOT), None)
+ atomic_write(registry_path, json.dumps(registry, indent=2) + '\n')
+ for path in (STATE, ROOT / '.env.worktree', ROOT / 'backend/app.env.worktree',
+ ROOT / 'frontend/.env.worktree', LOCAL / 'database.env'):
+ path.unlink(missing_ok=True)
+
+
+def write_env(path, values):
+ # Single-quoted dotenv values disable Compose interpolation (including $).
+ lines = ['# Generated by talebound-worktree-dev. Do not commit or copy.']
+ for key, value in values.items():
+ value = str(value)
+ if any(char in value for char in "\n\r'\\"):
+ raise RuntimeError(f'Unsupported character in {key}; values must be single-line literals.')
+ lines.append(f"{key}='{value}'")
+ atomic_write(path, '\n'.join(lines) + '\n')
+
+
+def integrations():
+ path = LOCAL / 'integrations.env'
+ if not path.exists():
+ atomic_write(path, '# Optional development-only Cloudflare settings. No shell expansion.\n'
+ '# CLOUDFLARE_API_TOKEN=\n# CLOUDFLARE_ACCOUNT_ID=\n'
+ '# NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=\n')
+ result = {}
+ for line in path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ key, separator, value = line.partition('=')
+ if not separator or key not in EXTERNAL_KEYS:
+ raise RuntimeError(f'Unsupported setting in {path}: {key}')
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
+ value = value[1:-1]
+ result[key] = value
+ return result
+
+
+def generate_env(state):
+ host, ports = state['host'], state['ports']
+ external = integrations()
+ root_env = dict(TB_ROOT=ROOT, TB_PROJECT=state['project'], TB_HOST=host,
+ TB_UID=os.getuid(), TB_GID=os.getgid(), TB_DB_PASSWORD=state['db_password'])
+ root_env.update({f'TB_{name.upper()}_PORT': value for name, value in ports.items()})
+ write_env(ROOT / '.env.worktree', root_env)
+ backend = dict(
+ ENVIRONMENT='development', DB_DRIVER='postgres',
+ DB_SOURCE=f"postgresql://root:{state['db_password']}@postgres:5432/talebound?sslmode=disable",
+ HTTP_SERVER_ADDRESS='0.0.0.0:8080', GRPC_SERVER_ADDRESS='0.0.0.0:9090',
+ REDIS_ADDRESS='redis:6379', TOKEN_SYMMETRIC_KEY=state['token_key'],
+ ACCESS_TOKEN_DURATION='15m', REFRESH_TOKEN_DURATION='24h',
+ COOKIE_DOMAIN='', FULL_DOMAIN=f"http://{host}:{ports['web']}",
+ EMAIL_SENDER_NAME='Talebound-local', EMAIL_SENDER_ADDRESS='noreply@talebound.test',
+ SMTP_ENDPOINT='mailpit:1025', SMTP_USERNAME='', SMTP_PASSWORD='',
+ SENTRY_DSN='', SENTRY_TRACES_SAMPLE_RATE='0',
+ CLOUDFLARE_API_TOKEN=external.get('CLOUDFLARE_API_TOKEN', ''),
+ CLOUDFLARE_ACCOUNT_ID=external.get('CLOUDFLARE_ACCOUNT_ID', ''),
+ )
+ write_env(ROOT / 'backend/app.env.worktree', backend)
+ frontend = dict(
+ NEXT_PUBLIC_API_URL=f"http://{host}:{ports['api']}",
+ NEXT_PUBLIC_RECAPTCHA_SITE_KEY='6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI',
+ NEXT_PUBLIC_CLOUDFLARE_HOSTNAME='imagedelivery.net', NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH='',
+ )
+ frontend.update({key: value for key, value in external.items() if key.startswith('NEXT_PUBLIC_')})
+ write_env(ROOT / 'frontend/.env.worktree', frontend)
+ write_env(LOCAL / 'database.env', dict(
+ PGHOST=host, PGPORT=ports['db'], PGDATABASE='talebound', PGUSER='root',
+ PGPASSWORD=state['db_password'], PGSSLMODE='disable'))
+
+
+def compose(state, *args, **kwargs):
+ # Ambient COMPOSE_* and TB_* variables must not redirect the selected stack.
+ env = {key: value for key, value in os.environ.items()
+ if not key.startswith(('COMPOSE_', 'TB_'))}
+ return run('docker', 'compose', '--project-name', state['project'],
+ '--env-file', str(ROOT / '.env.worktree'),
+ '-f', str(ROOT / 'scripts/worktree-dev/compose.yaml'), *args, env=env, **kwargs)
+
+
+def expected_labels(state):
+ return {'com.talebound.managed': 'worktree-dev', 'com.talebound.worktree': str(ROOT),
+ 'com.talebound.identity': state['project']}
+
+
+def assert_owned(state):
+ """Inspect before mutations, including resources bearing only Compose labels."""
+ project = state['project']
+ containers = run('docker', 'ps', '-aq', '--filter', f'label=com.docker.compose.project={project}', capture=True).stdout.split()
+ resources = [('container', name) for name in containers]
+ names = set(run('docker', 'ps', '-a', '--format', '{{.Names}}', capture=True).stdout.split())
+ resources.extend(('container', f'{project}-{service}-1') for service in SERVICES
+ if f'{project}-{service}-1' in names)
+ for kind, candidates in [('volume', [f'{project}_{v}' for v in VOLUMES]),
+ ('network', [f'{project}_default'])]:
+ existing = set(run('docker', kind, 'ls', '--format', '{{.Name}}', capture=True).stdout.split())
+ resources.extend((kind, name) for name in candidates if name in existing)
+ labelled = run('docker', kind, 'ls', '--filter', f'label=com.docker.compose.project={project}',
+ '--format', '{{.Name}}', capture=True).stdout.split()
+ resources.extend((kind, name) for name in labelled)
+ for kind, name in sorted(set(resources)):
+ data = json.loads(run('docker', kind, 'inspect', name, capture=True).stdout)[0]
+ labels = (data['Config'].get('Labels') if kind == 'container' else data.get('Labels')) or {}
+ if any(labels.get(key) != value for key, value in expected_labels(state).items()):
+ raise RuntimeError(f'Refusing to touch {kind} {name}: worktree ownership labels do not match.')
+
+
+def setup(state, services_only=False):
+ generate_env(state)
+ assert_owned(state)
+ compose(state, 'up', '-d', '--wait', '--wait-timeout', '120', 'postgres', 'redis', 'mailpit')
+ if not services_only:
+ compose(state, 'run', '--rm', '--no-deps', 'frontend',
+ 'node', '.yarn/releases/yarn-3.6.1.cjs', 'install', '--immutable')
+ compose(state, 'run', '--rm', '--no-deps', 'backend', 'migrate')
+
+
+def status(state, as_json=False):
+ public = dict(root=str(ROOT), project=state['project'], host=state['host'], ports=state['ports'],
+ frontend=f"http://{state['host']}:{state['ports']['web']}",
+ api=f"http://{state['host']}:{state['ports']['api']}",
+ inbox=f"http://{state['host']}:{state['ports']['mail']}")
+ if as_json:
+ print(json.dumps(public, indent=2))
+ return
+ print(f"Worktree: {ROOT}\nProject: {state['project']}\nFrontend: {public['frontend']}\n"
+ f"API: {public['api']}\nSwagger: {public['api']}/swagger/\nInbox: {public['inbox']}\n"
+ f"Database: {state['host']}:{state['ports']['db']}/talebound\n"
+ f"DB login: {LOCAL / 'database.env'}", flush=True)
+ compose(state, 'ps', '--all')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ commands = parser.add_subparsers(dest='command', required=True)
+ for name in ('setup', 'up'):
+ sub = commands.add_parser(name, help='Provision this worktree' if name == 'setup' else 'Provision and start the app')
+ if name == 'setup':
+ sub.add_argument('--services-only', action='store_true', help='Start data services only; skip dependencies and migrations')
+ sub = commands.add_parser('status', help='Show addresses and container status, without secrets')
+ sub.add_argument('--json', action='store_true')
+ sub = commands.add_parser('down', help='Stop this worktree, retaining data by default')
+ sub.add_argument('--purge-data', action='store_true', help='Delete only this worktree\'s labelled containers, network and volumes')
+ sub = commands.add_parser('logs', help='Show recent logs')
+ sub.add_argument('service', choices=SERVICES, nargs='?')
+ sub = commands.add_parser('restart', help='Reload the backend source snapshot or frontend configuration')
+ sub.add_argument('service', choices=('backend', 'frontend'), nargs='?', default='backend')
+ commands.add_parser('migrate', help='Apply migrations to this worktree database')
+ commands.add_parser('test-backend', help='Run Go short tests against this worktree database')
+ commands.add_parser('db', help='Run psql; accepts SQL on stdin')
+ args = parser.parse_args()
+ if Path(git('rev-parse', '--show-toplevel')).resolve() != ROOT:
+ raise RuntimeError('Run this from a Talebound Git checkout.')
+ LOCAL.mkdir(mode=0o700, exist_ok=True)
+ os.chmod(LOCAL, 0o700)
+ with locked(LOCAL / 'lifecycle.lock'):
+ if args.command in ('setup', 'up'):
+ state = create_state()
+ setup(state, getattr(args, 'services_only', False))
+ if args.command == 'up':
+ compose(state, 'up', '-d', '--force-recreate', '--wait', '--wait-timeout', '600', 'backend', 'frontend')
+ status(state)
+ return
+ if not STATE.exists():
+ if args.command == 'status':
+ print('This worktree is not set up. Run scripts/worktree-dev/bootstrap-worktree.sh')
+ return
+ raise RuntimeError('Run setup first.')
+ state = load_state()
+ if args.command == 'status':
+ status(state, args.json)
+ return
+ assert_owned(state)
+ if args.command == 'down':
+ if args.purge_data:
+ print(f"Purging only {state['project']} at {ROOT}: {', '.join(VOLUMES)}", flush=True)
+ compose(state, 'down', '--volumes')
+ forget_state()
+ else:
+ compose(state, 'stop')
+ elif args.command == 'logs':
+ compose(state, 'logs', '--tail', '100', *([args.service] if args.service else []))
+ elif args.command == 'restart':
+ generate_env(state)
+ compose(state, 'up', '-d', '--no-deps', '--force-recreate', '--wait', '--wait-timeout', '600', args.service)
+ elif args.command in ('migrate', 'test-backend'):
+ generate_env(state)
+ compose(state, 'run', '--rm', '--no-deps', 'backend',
+ 'migrate' if args.command == 'migrate' else 'test')
+ elif args.command == 'db':
+ compose(state, 'exec', '-T', 'postgres', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-U', 'root', '-d', 'talebound')
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error:
+ print(f'Error: {error}', file=sys.stderr)
+ sys.exit(1)
+ except KeyboardInterrupt:
+ sys.exit(130)