From 7302cc52a70d271b7d1c765d8d98fa7e33d67a3e Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Wed, 25 Feb 2026 04:17:40 +0100 Subject: [PATCH] Add initial project structure with Maven, Quarkus, and React setup - Created .gitignore to exclude build artifacts and IDE files. - Added pom.xml for Maven project configuration, including dependencies for Quarkus, OpenAPI, and testing. - Introduced project documentation outlining development workflow, architecture, and coding standards. - Implemented core business logic with services for authentication, scenario management, story lifecycle, and user management. - Established security context for JWT-based authentication. - Generated API specifications and client code for frontend integration. - Set up initial database entities and repositories for user, scenario, and story management. --- .cursor/rules/story-teller-project.mdc | 69 + .gitignore | 35 + docs/development-workflow.md | 109 + docs/story-teller-specification.md | 364 ++++ pom.xml | 250 +++ .../storyteller/business/AuthService.java | 69 + .../storyteller/business/ScenarioService.java | 183 ++ .../storyteller/business/StoryService.java | 373 ++++ .../storyteller/business/UserService.java | 130 ++ .../common/security/AuthContext.java | 43 + .../common/security/JwtService.java | 32 + .../common/security/PasswordHasher.java | 32 + .../data/entity/ScenarioCharacterEntity.java | 62 + .../data/entity/ScenarioEntity.java | 95 + .../data/entity/StoryCharacterEntity.java | 62 + .../storyteller/data/entity/StoryEntity.java | 104 + .../data/entity/StoryStepEntity.java | 81 + .../storyteller/data/entity/UserEntity.java | 81 + .../ScenarioCharacterRepository.java | 33 + .../data/repository/ScenarioRepository.java | 23 + .../repository/StoryCharacterRepository.java | 33 + .../data/repository/StoryRepository.java | 23 + .../data/repository/StoryStepRepository.java | 57 + .../data/repository/UserRepository.java | 23 + .../fascade/rest/AuthResource.java | 34 + .../fascade/rest/ScenariosResource.java | 68 + .../fascade/rest/StoriesResource.java | 91 + .../fascade/rest/UsersResource.java | 54 + src/main/resources/application.properties | 25 + .../db/migration/db.changelog-master.yaml | 281 +++ .../resources/openapi/story-teller-api.yaml | 506 +++++ src/main/resources/privateKey.pem | 28 + src/main/resources/publicKey.pem | 9 + src/main/web/index.html | 12 + src/main/web/package-lock.json | 1906 +++++++++++++++++ src/main/web/package.json | 24 + src/main/web/src/App.tsx | 51 + src/main/web/src/api/client.ts | 39 + .../web/src/components/ProtectedRoute.tsx | 19 + src/main/web/src/context/AuthContext.tsx | 57 + src/main/web/src/main.tsx | 16 + src/main/web/src/pages/AdminUsersPage.tsx | 126 ++ src/main/web/src/pages/HomePage.tsx | 23 + src/main/web/src/pages/LoginPage.tsx | 50 + src/main/web/src/pages/ScenariosPage.tsx | 144 ++ src/main/web/src/pages/StoriesPage.tsx | 85 + src/main/web/src/pages/StoryWorkspacePage.tsx | 275 +++ src/main/web/src/styles.css | 162 ++ src/main/web/tsconfig.app.json | 17 + src/main/web/tsconfig.app.tsbuildinfo | 1 + src/main/web/tsconfig.json | 7 + src/main/web/tsconfig.node.json | 12 + src/main/web/tsconfig.node.tsbuildinfo | 1 + src/main/web/vite.config.d.ts | 2 + src/main/web/vite.config.js | 15 + src/main/web/vite.config.ts | 16 + .../storyteller/business/AuthServiceTest.java | 97 + .../business/ScenarioServiceTest.java | 82 + .../business/StoryServiceTest.java | 154 ++ .../storyteller/business/UserServiceTest.java | 88 + .../common/security/PasswordHasherTest.java | 35 + 61 files changed, 6978 insertions(+) create mode 100644 .cursor/rules/story-teller-project.mdc create mode 100644 .gitignore create mode 100644 docs/development-workflow.md create mode 100644 docs/story-teller-specification.md create mode 100644 pom.xml create mode 100644 src/main/java/de/neitzel/storyteller/business/AuthService.java create mode 100644 src/main/java/de/neitzel/storyteller/business/ScenarioService.java create mode 100644 src/main/java/de/neitzel/storyteller/business/StoryService.java create mode 100644 src/main/java/de/neitzel/storyteller/business/UserService.java create mode 100644 src/main/java/de/neitzel/storyteller/common/security/AuthContext.java create mode 100644 src/main/java/de/neitzel/storyteller/common/security/JwtService.java create mode 100644 src/main/java/de/neitzel/storyteller/common/security/PasswordHasher.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/ScenarioCharacterEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/ScenarioEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/StoryCharacterEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/StoryEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/StoryStepEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/entity/UserEntity.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/ScenarioCharacterRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/ScenarioRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/StoryCharacterRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/StoryRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/data/repository/UserRepository.java create mode 100644 src/main/java/de/neitzel/storyteller/fascade/rest/AuthResource.java create mode 100644 src/main/java/de/neitzel/storyteller/fascade/rest/ScenariosResource.java create mode 100644 src/main/java/de/neitzel/storyteller/fascade/rest/StoriesResource.java create mode 100644 src/main/java/de/neitzel/storyteller/fascade/rest/UsersResource.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/db/migration/db.changelog-master.yaml create mode 100644 src/main/resources/openapi/story-teller-api.yaml create mode 100644 src/main/resources/privateKey.pem create mode 100644 src/main/resources/publicKey.pem create mode 100644 src/main/web/index.html create mode 100644 src/main/web/package-lock.json create mode 100644 src/main/web/package.json create mode 100644 src/main/web/src/App.tsx create mode 100644 src/main/web/src/api/client.ts create mode 100644 src/main/web/src/components/ProtectedRoute.tsx create mode 100644 src/main/web/src/context/AuthContext.tsx create mode 100644 src/main/web/src/main.tsx create mode 100644 src/main/web/src/pages/AdminUsersPage.tsx create mode 100644 src/main/web/src/pages/HomePage.tsx create mode 100644 src/main/web/src/pages/LoginPage.tsx create mode 100644 src/main/web/src/pages/ScenariosPage.tsx create mode 100644 src/main/web/src/pages/StoriesPage.tsx create mode 100644 src/main/web/src/pages/StoryWorkspacePage.tsx create mode 100644 src/main/web/src/styles.css create mode 100644 src/main/web/tsconfig.app.json create mode 100644 src/main/web/tsconfig.app.tsbuildinfo create mode 100644 src/main/web/tsconfig.json create mode 100644 src/main/web/tsconfig.node.json create mode 100644 src/main/web/tsconfig.node.tsbuildinfo create mode 100644 src/main/web/vite.config.d.ts create mode 100644 src/main/web/vite.config.js create mode 100644 src/main/web/vite.config.ts create mode 100644 src/test/java/de/neitzel/storyteller/business/AuthServiceTest.java create mode 100644 src/test/java/de/neitzel/storyteller/business/ScenarioServiceTest.java create mode 100644 src/test/java/de/neitzel/storyteller/business/StoryServiceTest.java create mode 100644 src/test/java/de/neitzel/storyteller/business/UserServiceTest.java create mode 100644 src/test/java/de/neitzel/storyteller/common/security/PasswordHasherTest.java diff --git a/.cursor/rules/story-teller-project.mdc b/.cursor/rules/story-teller-project.mdc new file mode 100644 index 0000000..db3f9cb --- /dev/null +++ b/.cursor/rules/story-teller-project.mdc @@ -0,0 +1,69 @@ +--- +description: StoryTeller project context, architecture, and coding standards (Java/Quarkus, Maven, frontend) +alwaysApply: true +--- + +# StoryTeller – Project Instructions + +## Project context + +- **Project**: StoryTeller +- **Java**: 21 +- **Build**: Maven +- **Framework**: Quarkus (latest stable) +- **GroupId**: de.neitzel | **ArtifactId**: storyteller +- **Base package**: `de.neitzel.storyteller` +- **Sub-packages**: `business`, `common`, `data`, `fascade` +- Startup code lives in the base package. +- **DB migrations**: Liquibase; scripts in `src/main/resources/db/migration`. + +## Architecture and package rules + +- **Startup/bootstrap**: `de.neitzel.storyteller` +- **Business logic**: `de.neitzel.storyteller.business` +- **Shared utilities, cross-cutting types**: `de.neitzel.storyteller.common` +- **Persistence / data access**: `de.neitzel.storyteller.data` +- **External-facing facades (REST, API)**: `de.neitzel.storyteller.fascade` +- Keep clear package boundaries; avoid circular dependencies. + +## Coding standards (Java) + +- Use Lombok to reduce boilerplate where it helps. +- Prefer immutability: `final` fields, constructor injection. +- Use Quarkus idioms; avoid heavyweight frameworks. +- Keep methods small and focused; one responsibility per class. +- Use Java 21 features when they improve clarity. +- Add concise comments only for non-obvious logic. +- All classes, fields, and methods (including private) must have JavaDoc describing purpose and usage. + +## Testing + +- Add or update unit tests for new or changed logic. +- Use JUnit 5 and Mockito; Arrange–Act–Assert; keep tests deterministic. +- Mock external dependencies; add integration tests only when requested. + +## Maven and dependencies + +- Target Java 21 in the Maven toolchain. +- Use the latest stable Quarkus platform BOM. +- Keep dependencies minimal; no new libraries without need. +- Keep versions up to date (especially for security). +- Put plugin and dependency versions in the `properties` section of `pom.xml`. +- Use `quarkus-maven-plugin` for build/run; `maven-surefire-plugin` for tests (JUnit 5); `maven-compiler-plugin` with Java 21. + +## Frontend (React / Vite) + +- Frontend lives under `src/main/web` (React, TypeScript, Vite, MUI). +- OpenAPI TypeScript client is generated into `src/main/web/src/api/generated/` by Maven (`mvn generate-sources`); do not edit by hand. +- Production build output: `target/web-dist`; copied to `META-INF/resources` by `maven-resources-plugin` for the Quarkus SPA. + +## Do and don’t + +- **Do**: keep code in the correct package; add tests for business logic. +- **Don’t**: put startup code outside the base package; add unnecessary abstraction layers. + +## Output expectations + +- Provide complete, compilable code with package declarations and imports. +- Prefer Quarkus-friendly APIs and annotations. +- When adding or changing logic, update or add unit tests. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e17609b --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +/target/ +/src/main/web/node_modules/ +/src/main/web/node/ +/.idea/ +/.DS_Store +# Maven / Java build output +target/ + +# Frontend (src/main/web) temporary and build artifacts +src/main/web/node_modules/ +src/main/web/dist/ +src/main/web/.vite/ +src/main/web/.cache/ +src/main/web/.eslintcache +src/main/web/coverage/ +src/main/web/npm-debug.log* +src/main/web/yarn-debug.log* +src/main/web/yarn-error.log* +src/main/web/pnpm-debug.log* + +# IntelliJ IDEA +.idea/ +*.iml +out/ + +# Other common IDE/editor files +.vscode/ +*.swp +*.swo + +# Generated API client (regenerated by mvn generate-sources) +src/main/web/src/api/generated/ + +# OS files +.DS_Store diff --git a/docs/development-workflow.md b/docs/development-workflow.md new file mode 100644 index 0000000..6703410 --- /dev/null +++ b/docs/development-workflow.md @@ -0,0 +1,109 @@ +## StoryTeller Development Workflow + +### Stack and Layout + +- **Backend**: Quarkus 3.31.2 (Java 21, Maven). +- **Frontend**: React 18 + TypeScript + Vite in `src/main/web`. +- **Database**: PostgreSQL with Liquibase migrations. +- **Auth**: SmallRye JWT with RSA key signing; BCrypt password hashing. +- **API Contract**: OpenAPI 3.0.3 at `src/main/resources/openapi/story-teller-api.yaml`. +- **Generated Code**: + - Java interfaces/models: `target/generated-sources/openapi/java/src/gen/java/` + - TypeScript client: `src/main/web/src/api/generated/` + +### Local Setup + +1. **Prerequisites**: JDK 21, Maven 3.9+, PostgreSQL, Node.js 22 (managed by frontend-maven-plugin). +2. **Database**: Create a PostgreSQL database: + ```sql + CREATE DATABASE storyteller; + CREATE USER storyteller WITH PASSWORD 'storyteller'; + GRANT ALL PRIVILEGES ON DATABASE storyteller TO storyteller; + ``` +3. **Build everything**: `mvn package` (generates API code, compiles Java, builds frontend, runs tests). +4. **Run in dev mode**: `mvn quarkus:dev` (hot reload, frontend proxy at http://localhost:5173). +5. **Frontend dev**: `cd src/main/web && npm run dev` (standalone Vite dev server, proxies `/api` to Quarkus). + +### Default Admin Account + +After first startup, Liquibase seeds an admin user: +- **Username**: `admin` +- **Password**: `admin` + +Change this immediately in non-development environments. + +### OpenAPI-First Rules + +- The OpenAPI YAML is the **single source of truth** for backend and frontend API contracts. +- **Never edit generated files** in `target/generated-sources/` or `src/main/web/src/api/generated/`. +- To change the API: + 1. Edit `src/main/resources/openapi/story-teller-api.yaml`. + 2. Run `mvn generate-sources` to regenerate Java and TypeScript. + 3. Update service/resource implementations to match new interfaces. + +### Common Commands + +| Command | Description | +|---|---| +| `mvn generate-sources` | Regenerate Java+TS from OpenAPI | +| `mvn compile` | Compile Java backend | +| `mvn test` | Run unit tests | +| `mvn package` | Full build: Java + frontend + tests | +| `mvn quarkus:dev` | Dev mode with hot reload | +| `cd src/main/web && npm run dev` | Frontend dev server | + +### Architecture Overview + +``` +de.neitzel.storyteller +├── business/ # Business logic services +├── common/ +│ └── security/ # JWT, password hashing, auth context +├── data/ +│ ├── entity/ # JPA entities +│ └── repository/ # Panache repositories +└── fascade/ + ├── api/ # Generated OpenAPI interfaces (DO NOT EDIT) + ├── model/ # Generated OpenAPI models (DO NOT EDIT) + └── rest/ # REST resource implementations +``` + +### Authentication Flow + +1. Client sends `POST /api/auth/login` with username/password. +2. Server verifies credentials, returns signed JWT with user claims. +3. Client stores JWT in localStorage and sends it as `Authorization: Bearer ` on all subsequent requests. +4. Quarkus SmallRye JWT validates the token on protected endpoints. +5. `AuthContext` (request-scoped CDI bean) extracts user identity from the JWT. + +### Authorization + +- **Public**: `/api/auth/*` +- **Admin-only**: `/api/users/*` (user management) +- **Authenticated users**: `/api/scenarios/*`, `/api/stories/*` (with per-user ownership checks) + +### Story Generation & Compaction + +The AI step generation flow includes automatic context compaction: + +1. Client sends a direction prompt. +2. Server estimates context size (scene + characters + unmerged steps + direction). +3. If it exceeds the limit (~12K chars), the oldest half of unmerged steps are compacted into the scene description. +4. Steps are marked `addedToScene = true` after compaction. +5. The AI generates the next step from the (possibly compacted) context. +6. The response includes a `compacted` flag so the frontend can prompt the user to review the updated scene. + +**Note**: The current AI adapter is a stub that generates placeholder text. Replace the `generateAiContinuation` and `compactScene` methods in `StoryService` with actual LLM integration. + +### Testing + +- Unit tests use JUnit 5 + Mockito in `src/test/java/`. +- Tests cover auth, user service, scenario service, and story service. +- Run with `mvn test`. + +### Troubleshooting + +- **JWT errors**: Ensure `privateKey.pem` and `publicKey.pem` exist in `src/main/resources/`. +- **Generated code issues**: Run `mvn clean generate-sources` to rebuild from scratch. +- **TypeScript errors**: Check that `src/main/web/node_modules` exists; run `npm install` in `src/main/web`. +- **DB connection**: Verify PostgreSQL is running and the `storyteller` database/user exist. diff --git a/docs/story-teller-specification.md b/docs/story-teller-specification.md new file mode 100644 index 0000000..7043b1e --- /dev/null +++ b/docs/story-teller-specification.md @@ -0,0 +1,364 @@ +## Story Teller – Application Specification + +### 1. Overview + +**Story Teller** is a web application where an AI collaboratively builds a story step by step with the user. + +The user starts from a predefined scenario (starting scene and characters), gives directions about what should happen next, optionally edits the scene and characters, and the AI produces the next part of the story. The system manages scenes, story steps, and automatic context compaction when AI prompts become too large. + +--- + +### 2. User Roles & Permissions + +#### 2.1 Roles + +- **Regular user** + - Authenticate via simple login. + - Manage own content: + - Create, edit, delete **scenarios** (starting points). + - Create, edit, delete **starting characters** associated with their scenarios. + - Start new **stories** from scenarios they own. + - Load existing stories they own and continue them. + - Edit and merge steps, scenes, and characters within their own stories. + - Delete their own stories. + +- **Admin user** + - All capabilities of regular users. + - Additional capabilities: + - Create new users. + - View list of all users. + - Edit user information (e.g., username, admin flag, active flag, password reset). + - Delete users (with defined behavior for their content). + +> Anonymous users are not supported; all access is via login. + +--- + +### 3. Core Domain Model + +#### 3.1 User + +- **Fields** + - `id` + - `username` (unique) + - `passwordHash` + - `isAdmin` (boolean) + - `active` (boolean) + - `createdAt` + - `updatedAt` + +#### 3.2 Scenario (Starting Point) + +- **Fields** + - `id` + - `ownerUserId` + - `title` + - `description` (overall scenario description) + - `startingSceneDescription` (initial scene narrative text) + - `startingCharacters` (list of `ScenarioCharacter`) + - `createdAt` + - `updatedAt` + +- **ScenarioCharacter** + - `id` + - `scenarioId` + - `name` + - `role` (e.g., “protagonist”, “villain”, “guide”) + - `description` + +#### 3.3 Story + +- **Fields** + - `id` + - `ownerUserId` + - `scenarioId` (reference to starting scenario) + - `title` + - `currentSceneDescription` (full narrative of the current scene) + - `currentSceneCharacters` (list of `StoryCharacter`) + - `status` (e.g., `ACTIVE`, `ARCHIVED`, `DELETED`) + - `createdAt` + - `updatedAt` + +- **StoryCharacter** + - `id` + - `storyId` + - `name` + - `role` + - `description` + - (Optional) `origin` (e.g., `FROM_SCENARIO`, `ADDED_IN_STORY`) + +#### 3.4 StoryStep + +- Represents a continuation segment in the story. + +- **Fields** + - `id` + - `storyId` + - `stepNumber` (monotonic integer starting from 1) + - `content` (text for this step: AI-written and/or user-edited) + - `addedToScene` (boolean; `true` if this step has been merged/compacted into the current scene) + - `userDirection` (the user’s “what should happen next” text used for this step) + - `createdAt` + - (Optional) `aiPromptMetadata` (model name, parameters, etc.) + +--- + +### 4. Functional Requirements + +#### 4.1 Authentication & User Management + +- **Login** + - User provides username and password. + - On success, user session is established (e.g., cookie or token-based). + - On failure, an error message is displayed. + +- **Admin user management** + - **List users** + - Paginated list with username, admin flag, active flag, and creation time. + - **Create user** + - Set username, initial password, and admin flag. + - **Edit user** + - Change password (reset). + - Toggle `isAdmin`. + - Toggle `active`. + - **Delete user** + - Remove user with defined behavior for their content (e.g., soft delete or cascade; to be decided at implementation). + +#### 4.2 Scenario & Starting Characters Management + +- **Create scenario** + - User defines: + - Title. + - Overall description (optional). + - Starting scene description. + - Initial set of starting characters (name, role, description). + +- **Edit scenario** + - Update title, descriptions, and characters: + - Add, edit, remove `ScenarioCharacter`s. + +- **Delete scenario** + - User can delete their own scenarios. + - Behavior if stories exist for this scenario (e.g., forbid deletion or only mark the scenario as inactive) is defined at implementation. + +- **List scenarios** + - Users see only scenarios they own. + - Admins can see all scenarios. + +#### 4.3 Story Lifecycle + +- **Start new story from a scenario** + - User selects a scenario. + - System initializes a `Story`: + - `currentSceneDescription` from `startingSceneDescription`. + - `currentSceneCharacters` from `startingCharacters`. + - No `StoryStep`s initially. + - User can optionally set a custom story title. + +- **Load existing story** + - User sees a list of their stories (title, originating scenario, last updated). + - Selecting a story shows: + - Current scene. + - Current scene characters. + - Story step list with `stepNumber` and `addedToScene` flag. + - Input area for “what should happen next”. + +- **Delete story** + - Users can delete their own stories. + - Implementation can choose soft delete (recommended) or hard delete. + +--- + +### 5. Story Interaction & AI Behavior + +#### 5.1 User Actions Per Step + +On an active story, the user can: + +- **Enter a description of what should happen next** + - Free text input for the direction of the next step. + +- **Modify the current scene** + - Direct text editing of `currentSceneDescription`. + - Or AI-assisted edit: + - User describes desired changes. + - AI proposes modified scene text. + - User can accept, reject, or further edit the proposal. + +- **Modify current characters** + - Directly: + - Change name, role, or description of existing characters. + - Remove characters. + - Add new characters. + - Or AI-assisted: + - User describes desired character changes (e.g., “make the mentor more mysterious”). + - AI proposes updates to character fields. + - User confirms or edits these suggestions. + +#### 5.2 Request Payload to AI (Normal Case) + +When generating the next story step without compaction: + +- **Request to AI must include:** + - **Current scene** + - `currentSceneDescription`. + - `currentSceneCharacters` (list of name, role, description). + - **User direction** + - The user’s text describing what should happen next for this step. + - **Unmerged steps** + - All `StoryStep` entries where `addedToScene == false`. + - Steps are sent in chronological order as additional context after the scene. + +- **AI output** + - Narrative text for the next part of the story (step content). + +- **System actions** + - Create a new `StoryStep` with: + - `stepNumber = max(existing stepNumber) + 1`. + - `content = AI response` (subsequently editable by user). + - `addedToScene = false`. + - `userDirection` stored for this step. + +#### 5.3 Context Compaction Flow (When Prompt Too Large) + +If the combined size of: +- Current scene description, +- Current characters, +- All unmerged steps, +- And the new user direction + +exceeds the AI model’s context limits, the system performs a **compaction phase** before requesting a new step. + +**Compaction phase (first AI call):** + +- Send to the AI: + - The current scene (description and characters). + - A subset of the unmerged `StoryStep`s, typically the oldest half of them. +- Ask the AI to: + - Merge and compact this information into a new, coherent scene description. + - Optionally adjust character descriptions to reflect changes implied by the merged steps. + +**Update of domain state:** + +- Replace `currentSceneDescription` with the compacted scene from the AI. +- Update `currentSceneCharacters` if the AI returned updated characters. +- Mark the merged `StoryStep`s as: + - `addedToScene = true`. + +**Repeated compaction (if needed):** + +- If context is still too large, repeat the compaction step with the remaining unmerged steps until: + - The full story history is representable by: + - One current scene, + - Current characters, + - A small remaining set of unmerged steps. + +**User review after compaction:** + +- Show the **new current scene and characters** to the user. +- Allow: + - Direct editing of the compacted scene and characters. + - AI-assisted modification (prompt–suggest–accept/edit loop). +- Once the user is satisfied, they can: + - Enter new “what should happen next” text. + - Trigger the normal “generate next step” AI call, which uses the compacted scene plus remaining unmerged steps. + +--- + +### 6. Frontend UX Requirements + +#### 6.1 Login & Admin Screens + +- **Login page** + - Username and password fields. + - Button to log in. + - Display of error messages for invalid credentials. + +- **Admin user management** + - User list table showing: + - Username. + - Admin flag. + - Active flag. + - Actions: + - Create user (form). + - Edit user (change password, toggle admin/active). + - Delete user. + +#### 6.2 Scenario Management UI + +- **Scenario list** + - Displays scenarios owned by the logged-in user. + - Actions: + - Create scenario. + - Edit scenario. + - Delete scenario. + +- **Scenario editor** + - Fields: + - Title. + - Scenario description. + - Starting scene description. + - Character list: + - For each character: name, role, description. + - Add / edit / delete characters. + +#### 6.3 Story Workspace UI + +For a selected story, the workspace should provide: + +- **Current scene panel** + - Read-only by default with buttons: + - “Edit Scene” (direct text edit). + - “AI Edit Scene” (prompt-based suggestion). + +- **Characters panel** + - List of characters with name, role, and description. + - Actions: + - Add character. + - Edit character. + - Remove character. + - “AI Suggest Character Changes”. + +- **Steps panel** + - List of steps with: + - Step number. + - Short preview of `content`. + - Badge or indicator for `addedToScene` vs not. + - Clicking a step expands to show full `content`. + +- **Next step input** + - Text area: “What should happen next?” + - Button: “Generate Next Step”. + +- **Compaction UX** + - When compaction is triggered: + - Show progress indication. + - After completion, present the updated current scene and characters. + - Provide: + - “Accept Scene” (continue). + - “Edit Scene” (direct or AI-assisted). + +--- + +### 7. Non-Functional Requirements (High-Level) + +- **Persistence** + - Use a relational database to store `User`, `Scenario`, `ScenarioCharacter`, `Story`, `StoryCharacter`, and `StoryStep`. + +- **AI integration** + - Backend component to: + - Build AI prompts from current scene, characters, user direction, and steps. + - Check approximate prompt size against model limits. + - Trigger compaction workflow when limits are exceeded. + +- **Security** + - Authorization checks ensure: + - Users only access and modify their own scenarios and stories. + - Admin endpoints/actions are restricted to admin users. + +- **Auditability** + - Store: + - `userDirection` and AI-generated `content` per `StoryStep`. + - Optional metadata about AI calls for debugging and analysis. + +This specification describes the intended behavior and structure of the Story Teller application across users, scenarios, stories, AI integration, and frontend UX. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..738af62 --- /dev/null +++ b/pom.xml @@ -0,0 +1,250 @@ + + + 4.0.0 + + de.neitzel + storyteller + 0.1.0-SNAPSHOT + StoryTeller + OpenAPI-first StoryTeller application with Quarkus and React. + + + UTF-8 + 21 + io.quarkus.platform + quarkus-bom + 3.31.2 + ${quarkus.platform.version} + 7.14.0 + 3.6.0 + 1.15.1 + 3.3.1 + 3.5.3 + + + + + + ${quarkus.platform.group-id} + ${quarkus.platform.artifact-id} + ${quarkus.platform.version} + pom + import + + + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-security + + + io.quarkus + quarkus-smallrye-jwt + + + io.quarkus + quarkus-smallrye-jwt-build + + + io.quarkus + quarkus-hibernate-validator + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-postgresql + + + io.quarkus + quarkus-liquibase + + + io.swagger + swagger-annotations + 1.6.15 + + + org.mindrot + jbcrypt + 0.4 + + + io.quarkus + quarkus-junit + test + + + org.mockito + mockito-core + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.plugin.version} + true + + + + org.openapitools + openapi-generator-maven-plugin + ${openapi.generator.version} + + + generate-java-api + generate-sources + + generate + + + ${project.basedir}/src/main/resources/openapi/story-teller-api.yaml + jaxrs-spec + ${project.build.directory}/generated-sources/openapi/java + de.neitzel.storyteller.fascade.api + de.neitzel.storyteller.fascade.model + true + true + false + + true + true + true + java8 + src/gen/java + + + + + generate-typescript-client + generate-sources + + generate + + + ${project.basedir}/src/main/resources/openapi/story-teller-api.yaml + typescript-fetch + ${project.basedir}/src/main/web/src/api/generated + true + true + true + + true + @storyteller/api-client + original + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + ${build.helper.plugin.version} + + + add-generated-java-sources + generate-sources + + add-source + + + + ${project.build.directory}/generated-sources/openapi/java/src/gen/java + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend.maven.plugin.version} + + src/main/web + + + + install-node-and-npm + + install-node-and-npm + + + v22.14.0 + 10.9.2 + + + + npm-install + generate-resources + + npm + + + install + + + + npm-build + prepare-package + + npm + + + run build + + + + + + + maven-resources-plugin + ${maven.resources.plugin.version} + + + copy-web-dist-to-quarkus-static + prepare-package + + copy-resources + + + ${project.build.outputDirectory}/META-INF/resources + + + ${project.build.directory}/web-dist + false + + + + + + + + + maven-surefire-plugin + ${maven.surefire.plugin.version} + + + + + diff --git a/src/main/java/de/neitzel/storyteller/business/AuthService.java b/src/main/java/de/neitzel/storyteller/business/AuthService.java new file mode 100644 index 0000000..c96d11b --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/AuthService.java @@ -0,0 +1,69 @@ +package de.neitzel.storyteller.business; + +import de.neitzel.storyteller.common.security.JwtService; +import de.neitzel.storyteller.common.security.PasswordHasher; +import de.neitzel.storyteller.data.entity.UserEntity; +import de.neitzel.storyteller.data.repository.UserRepository; +import de.neitzel.storyteller.fascade.model.LoginRequest; +import de.neitzel.storyteller.fascade.model.LoginResponse; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; + +/** + * Handles user authentication: credential verification and JWT issuance. + */ +@ApplicationScoped +public class AuthService { + + /** User repository for credential lookup. */ + private final UserRepository userRepository; + + /** Password hashing utility. */ + private final PasswordHasher passwordHasher; + + /** JWT token builder. */ + private final JwtService jwtService; + + /** + * Constructs the service with required dependencies. + * + * @param userRepository user persistence + * @param passwordHasher BCrypt hasher + * @param jwtService token builder + */ + public AuthService(final UserRepository userRepository, + final PasswordHasher passwordHasher, + final JwtService jwtService) { + this.userRepository = userRepository; + this.passwordHasher = passwordHasher; + this.jwtService = jwtService; + } + + /** + * Authenticates the user and returns a signed JWT. + * + * @param request login payload with username and password + * @return login response containing the JWT and user info + * @throws WebApplicationException 401 if credentials are invalid + */ + public LoginResponse login(final LoginRequest request) { + final UserEntity user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new WebApplicationException("Invalid credentials", Response.Status.UNAUTHORIZED)); + + if (!user.isActive()) { + throw new WebApplicationException("Account is disabled", Response.Status.UNAUTHORIZED); + } + + if (!passwordHasher.verify(request.getPassword(), user.getPasswordHash())) { + throw new WebApplicationException("Invalid credentials", Response.Status.UNAUTHORIZED); + } + + final String token = jwtService.generateToken(user.getId(), user.getUsername(), user.isAdmin()); + return new LoginResponse() + .token(token) + .userId(user.getId()) + .username(user.getUsername()) + .isAdmin(user.isAdmin()); + } +} diff --git a/src/main/java/de/neitzel/storyteller/business/ScenarioService.java b/src/main/java/de/neitzel/storyteller/business/ScenarioService.java new file mode 100644 index 0000000..b4bef13 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/ScenarioService.java @@ -0,0 +1,183 @@ +package de.neitzel.storyteller.business; + +import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity; +import de.neitzel.storyteller.data.entity.ScenarioEntity; +import de.neitzel.storyteller.data.repository.ScenarioCharacterRepository; +import de.neitzel.storyteller.data.repository.ScenarioRepository; +import de.neitzel.storyteller.fascade.model.CharacterDto; +import de.neitzel.storyteller.fascade.model.CreateScenarioRequest; +import de.neitzel.storyteller.fascade.model.Scenario; +import de.neitzel.storyteller.fascade.model.UpdateScenarioRequest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Manages scenario CRUD operations with per-user ownership enforcement. + */ +@ApplicationScoped +public class ScenarioService { + + /** Scenario persistence. */ + private final ScenarioRepository scenarioRepository; + + /** Character persistence. */ + private final ScenarioCharacterRepository characterRepository; + + /** + * Constructs the service with required repositories. + * + * @param scenarioRepository scenario persistence + * @param characterRepository character persistence + */ + public ScenarioService(final ScenarioRepository scenarioRepository, + final ScenarioCharacterRepository characterRepository) { + this.scenarioRepository = scenarioRepository; + this.characterRepository = characterRepository; + } + + /** + * Lists scenarios visible to the calling user. Admins see all; regular users see only their own. + * + * @param userId calling user's id + * @param isAdmin whether the caller is an admin + * @return list of scenario DTOs + */ + public List listScenarios(final Long userId, final boolean isAdmin) { + final List entities = isAdmin + ? scenarioRepository.listAll() + : scenarioRepository.findByOwner(userId); + return entities.stream().map(this::toDto).toList(); + } + + /** + * Returns a single scenario by id with ownership check. + * + * @param scenarioId scenario id + * @param userId calling user's id + * @param isAdmin whether the caller is an admin + * @return scenario DTO + */ + public Scenario getScenario(final Long scenarioId, final Long userId, final boolean isAdmin) { + final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin); + return toDto(entity); + } + + /** + * Creates a new scenario owned by the calling user. + * + * @param request payload + * @param userId calling user's id + * @return created scenario DTO + */ + @Transactional + public Scenario createScenario(final CreateScenarioRequest request, final Long userId) { + final ScenarioEntity entity = new ScenarioEntity(); + entity.setOwnerUserId(userId); + entity.setTitle(request.getTitle()); + entity.setDescription(request.getDescription()); + entity.setStartingSceneDescription(request.getStartingSceneDescription()); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + scenarioRepository.persist(entity); + + if (request.getStartingCharacters() != null) { + for (final CharacterDto dto : request.getStartingCharacters()) { + persistCharacter(entity.getId(), dto); + } + } + scenarioRepository.getEntityManager().flush(); + scenarioRepository.getEntityManager().refresh(entity); + return toDto(entity); + } + + /** + * Updates an existing scenario with ownership check. + * + * @param scenarioId scenario id + * @param request update payload + * @param userId calling user's id + * @param isAdmin whether the caller is an admin + * @return updated scenario DTO + */ + @Transactional + public Scenario updateScenario(final Long scenarioId, final UpdateScenarioRequest request, + final Long userId, final boolean isAdmin) { + final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin); + if (request.getTitle() != null) { + entity.setTitle(request.getTitle()); + } + if (request.getDescription() != null) { + entity.setDescription(request.getDescription()); + } + if (request.getStartingSceneDescription() != null) { + entity.setStartingSceneDescription(request.getStartingSceneDescription()); + } + entity.setUpdatedAt(LocalDateTime.now()); + + if (request.getStartingCharacters() != null) { + characterRepository.deleteByScenario(scenarioId); + scenarioRepository.getEntityManager().flush(); + for (final CharacterDto dto : request.getStartingCharacters()) { + persistCharacter(scenarioId, dto); + } + } + scenarioRepository.getEntityManager().flush(); + scenarioRepository.getEntityManager().refresh(entity); + return toDto(entity); + } + + /** + * Deletes a scenario with ownership check. + * + * @param scenarioId scenario id + * @param userId calling user's id + * @param isAdmin whether the caller is an admin + */ + @Transactional + public void deleteScenario(final Long scenarioId, final Long userId, final boolean isAdmin) { + final ScenarioEntity entity = findOwnedOrFail(scenarioId, userId, isAdmin); + characterRepository.deleteByScenario(scenarioId); + scenarioRepository.delete(entity); + } + + /** + * Finds a scenario, throwing 404 if absent or 403 if ownership fails. + */ + private ScenarioEntity findOwnedOrFail(final Long scenarioId, final Long userId, final boolean isAdmin) { + final ScenarioEntity entity = scenarioRepository.findById(scenarioId); + if (entity == null) { + throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND); + } + if (!isAdmin && !entity.getOwnerUserId().equals(userId)) { + throw new WebApplicationException("Access denied", Response.Status.FORBIDDEN); + } + return entity; + } + + /** Persists a single character row for a scenario. */ + private void persistCharacter(final Long scenarioId, final CharacterDto dto) { + final ScenarioCharacterEntity ch = new ScenarioCharacterEntity(); + ch.setScenarioId(scenarioId); + ch.setName(dto.getName()); + ch.setRole(dto.getRole()); + ch.setDescription(dto.getDescription()); + characterRepository.persist(ch); + } + + /** Maps a ScenarioEntity + its characters to the API Scenario DTO. */ + private Scenario toDto(final ScenarioEntity entity) { + final List chars = entity.getStartingCharacters().stream() + .map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription())) + .toList(); + return new Scenario() + .id(entity.getId()) + .title(entity.getTitle()) + .description(entity.getDescription()) + .startingSceneDescription(entity.getStartingSceneDescription()) + .startingCharacters(chars); + } +} diff --git a/src/main/java/de/neitzel/storyteller/business/StoryService.java b/src/main/java/de/neitzel/storyteller/business/StoryService.java new file mode 100644 index 0000000..16a382c --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/StoryService.java @@ -0,0 +1,373 @@ +package de.neitzel.storyteller.business; + +import de.neitzel.storyteller.data.entity.ScenarioEntity; +import de.neitzel.storyteller.data.entity.StoryCharacterEntity; +import de.neitzel.storyteller.data.entity.StoryEntity; +import de.neitzel.storyteller.data.entity.StoryStepEntity; +import de.neitzel.storyteller.data.repository.ScenarioRepository; +import de.neitzel.storyteller.data.repository.StoryCharacterRepository; +import de.neitzel.storyteller.data.repository.StoryRepository; +import de.neitzel.storyteller.data.repository.StoryStepRepository; +import de.neitzel.storyteller.fascade.model.CharacterDto; +import de.neitzel.storyteller.fascade.model.GenerateStepRequest; +import de.neitzel.storyteller.fascade.model.GenerateStepResponse; +import de.neitzel.storyteller.fascade.model.StartStoryRequest; +import de.neitzel.storyteller.fascade.model.Story; +import de.neitzel.storyteller.fascade.model.StoryStep; +import de.neitzel.storyteller.fascade.model.UpdateStepRequest; +import de.neitzel.storyteller.fascade.model.UpdateStoryRequest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Manages the full story lifecycle: creation, scene/character editing, + * step generation with AI, and context compaction. + */ +@ApplicationScoped +public class StoryService { + + /** + * Approximate character budget before compaction kicks in. + * In a real system this would be configurable and model-specific. + */ + private static final int CONTEXT_CHAR_LIMIT = 12_000; + + /** Story persistence. */ + private final StoryRepository storyRepository; + /** Story character persistence. */ + private final StoryCharacterRepository characterRepository; + /** Story step persistence. */ + private final StoryStepRepository stepRepository; + /** Scenario persistence (for initial data on story start). */ + private final ScenarioRepository scenarioRepository; + + /** + * Constructs the service with required repositories. + * + * @param storyRepository story persistence + * @param characterRepository character persistence + * @param stepRepository step persistence + * @param scenarioRepository scenario persistence + */ + public StoryService(final StoryRepository storyRepository, + final StoryCharacterRepository characterRepository, + final StoryStepRepository stepRepository, + final ScenarioRepository scenarioRepository) { + this.storyRepository = storyRepository; + this.characterRepository = characterRepository; + this.stepRepository = stepRepository; + this.scenarioRepository = scenarioRepository; + } + + /** + * Lists non-deleted stories for the calling user. + * + * @param userId calling user's id + * @return story DTOs + */ + public List listStories(final Long userId) { + return storyRepository.findByOwner(userId).stream() + .map(this::toDto) + .toList(); + } + + /** + * Returns a single story by id with ownership check. + * + * @param storyId story id + * @param userId calling user's id + * @return story DTO + */ + public Story getStory(final Long storyId, final Long userId) { + return toDto(findOwnedOrFail(storyId, userId)); + } + + /** + * Starts a new story from a scenario, copying its scene and characters. + * + * @param request payload with scenario id and title + * @param userId calling user's id + * @return created story DTO + */ + @Transactional + public Story startStory(final StartStoryRequest request, final Long userId) { + final ScenarioEntity scenario = scenarioRepository.findById(request.getScenarioId()); + if (scenario == null) { + throw new WebApplicationException("Scenario not found", Response.Status.NOT_FOUND); + } + + final StoryEntity entity = new StoryEntity(); + entity.setOwnerUserId(userId); + entity.setScenarioId(scenario.getId()); + entity.setTitle(request.getTitle()); + entity.setCurrentSceneDescription(scenario.getStartingSceneDescription()); + entity.setStatus("ACTIVE"); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + storyRepository.persist(entity); + + scenario.getStartingCharacters().forEach(sc -> { + final StoryCharacterEntity ch = new StoryCharacterEntity(); + ch.setStoryId(entity.getId()); + ch.setName(sc.getName()); + ch.setRole(sc.getRole()); + ch.setDescription(sc.getDescription()); + characterRepository.persist(ch); + }); + + storyRepository.getEntityManager().flush(); + storyRepository.getEntityManager().refresh(entity); + return toDto(entity); + } + + /** + * Updates a story's title, scene description, and/or characters. + * + * @param storyId story id + * @param request update payload + * @param userId calling user's id + * @return updated story DTO + */ + @Transactional + public Story updateStory(final Long storyId, final UpdateStoryRequest request, final Long userId) { + final StoryEntity entity = findOwnedOrFail(storyId, userId); + if (request.getTitle() != null) { + entity.setTitle(request.getTitle()); + } + if (request.getCurrentSceneDescription() != null) { + entity.setCurrentSceneDescription(request.getCurrentSceneDescription()); + } + entity.setUpdatedAt(LocalDateTime.now()); + + if (request.getCurrentSceneCharacters() != null) { + characterRepository.deleteByStory(storyId); + storyRepository.getEntityManager().flush(); + for (final CharacterDto dto : request.getCurrentSceneCharacters()) { + final StoryCharacterEntity ch = new StoryCharacterEntity(); + ch.setStoryId(storyId); + ch.setName(dto.getName()); + ch.setRole(dto.getRole()); + ch.setDescription(dto.getDescription()); + characterRepository.persist(ch); + } + } + storyRepository.getEntityManager().flush(); + storyRepository.getEntityManager().refresh(entity); + return toDto(entity); + } + + /** + * Soft-deletes a story. + * + * @param storyId story id + * @param userId calling user's id + */ + @Transactional + public void deleteStory(final Long storyId, final Long userId) { + final StoryEntity entity = findOwnedOrFail(storyId, userId); + entity.setStatus("DELETED"); + entity.setUpdatedAt(LocalDateTime.now()); + } + + /** + * Lists all steps for a story, ordered by step number. + * + * @param storyId story id + * @param userId calling user's id + * @return step DTOs + */ + public List listStorySteps(final Long storyId, final Long userId) { + findOwnedOrFail(storyId, userId); + return stepRepository.findByStoryOrdered(storyId).stream() + .map(this::toStepDto) + .toList(); + } + + /** + * Updates a story step's content (user editing). + * + * @param storyId story id + * @param stepId step id + * @param request update payload + * @param userId calling user's id + * @return updated step DTO + */ + @Transactional + public StoryStep updateStoryStep(final Long storyId, final Long stepId, + final UpdateStepRequest request, final Long userId) { + findOwnedOrFail(storyId, userId); + final StoryStepEntity step = stepRepository.findById(stepId); + if (step == null || !step.getStoryId().equals(storyId)) { + throw new WebApplicationException("Step not found", Response.Status.NOT_FOUND); + } + if (request.getContent() != null) { + step.setContent(request.getContent()); + } + return toStepDto(step); + } + + /** + * Generates the next story step. Performs context compaction when the combined + * prompt size exceeds {@link #CONTEXT_CHAR_LIMIT}, then generates the continuation. + * + * @param storyId story id + * @param request generation request with user direction + * @param userId calling user's id + * @return generation response containing the new step and compaction flag + */ + @Transactional + public GenerateStepResponse generateStoryStep(final Long storyId, + final GenerateStepRequest request, + final Long userId) { + final StoryEntity story = findOwnedOrFail(storyId, userId); + + if (request.getCurrentSceneDescription() != null) { + story.setCurrentSceneDescription(request.getCurrentSceneDescription()); + } + if (request.getCurrentCharacters() != null) { + characterRepository.deleteByStory(storyId); + storyRepository.getEntityManager().flush(); + for (final CharacterDto dto : request.getCurrentCharacters()) { + final StoryCharacterEntity ch = new StoryCharacterEntity(); + ch.setStoryId(storyId); + ch.setName(dto.getName()); + ch.setRole(dto.getRole()); + ch.setDescription(dto.getDescription()); + characterRepository.persist(ch); + } + } + storyRepository.getEntityManager().flush(); + + List unmerged = stepRepository.findUnmergedByStory(storyId); + boolean compacted = false; + + while (estimateContextSize(story, unmerged, request.getDirection()) > CONTEXT_CHAR_LIMIT + && unmerged.size() > 1) { + compacted = true; + final int half = Math.max(1, unmerged.size() / 2); + final List toMerge = unmerged.subList(0, half); + final String compactedScene = compactScene(story.getCurrentSceneDescription(), toMerge); + story.setCurrentSceneDescription(compactedScene); + toMerge.forEach(s -> s.setAddedToScene(true)); + storyRepository.getEntityManager().flush(); + unmerged = stepRepository.findUnmergedByStory(storyId); + } + + story.setUpdatedAt(LocalDateTime.now()); + + final int nextNumber = stepRepository.maxStepNumber(storyId) + 1; + final String content = generateAiContinuation(story, unmerged, request.getDirection()); + final StoryStepEntity step = new StoryStepEntity(); + step.setStoryId(storyId); + step.setStepNumber(nextNumber); + step.setContent(content); + step.setUserDirection(request.getDirection()); + step.setAddedToScene(false); + step.setCreatedAt(LocalDateTime.now()); + stepRepository.persist(step); + + storyRepository.getEntityManager().refresh(story); + + final GenerateStepResponse response = new GenerateStepResponse() + .step(toStepDto(step)) + .compacted(compacted); + if (compacted) { + response.setUpdatedScene(story.getCurrentSceneDescription()); + response.setUpdatedCharacters(story.getCurrentSceneCharacters().stream() + .map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription())) + .toList()); + } + return response; + } + + /** + * Rough character count estimator for context window sizing. + * In production this would use tokenization. + */ + private int estimateContextSize(final StoryEntity story, + final List unmerged, + final String direction) { + int size = story.getCurrentSceneDescription().length(); + for (final StoryCharacterEntity ch : story.getCurrentSceneCharacters()) { + size += ch.getName().length() + ch.getRole().length() + + (ch.getDescription() != null ? ch.getDescription().length() : 0); + } + for (final StoryStepEntity s : unmerged) { + size += s.getContent().length(); + } + size += direction != null ? direction.length() : 0; + return size; + } + + /** + * AI stub: compacts a scene description by summarising the merged steps into the scene. + * In production this would call an LLM. + */ + private String compactScene(final String currentScene, final List stepsToMerge) { + final StringBuilder sb = new StringBuilder(currentScene); + sb.append("\n\n[Compacted from ").append(stepsToMerge.size()).append(" earlier steps:]\n"); + for (final StoryStepEntity s : stepsToMerge) { + sb.append("- ").append(truncate(s.getContent(), 200)).append("\n"); + } + return sb.toString(); + } + + /** + * AI stub: generates the next story segment from the current context. + * In production this would call an LLM. + */ + private String generateAiContinuation(final StoryEntity story, + final List unmerged, + final String direction) { + return "[AI continuation] Direction: " + direction + + "\nScene: " + truncate(story.getCurrentSceneDescription(), 120) + + "\nUnmerged steps: " + unmerged.size(); + } + + /** Truncates text for display in stubs. */ + private String truncate(final String text, final int max) { + if (text == null) return ""; + return text.length() > max ? text.substring(0, max) + "..." : text; + } + + /** Finds a story ensuring ownership, throwing 404/403 as needed. */ + private StoryEntity findOwnedOrFail(final Long storyId, final Long userId) { + final StoryEntity entity = storyRepository.findById(storyId); + if (entity == null || "DELETED".equals(entity.getStatus())) { + throw new WebApplicationException("Story not found", Response.Status.NOT_FOUND); + } + if (!entity.getOwnerUserId().equals(userId)) { + throw new WebApplicationException("Access denied", Response.Status.FORBIDDEN); + } + return entity; + } + + /** Maps a StoryEntity to the API Story DTO. */ + private Story toDto(final StoryEntity entity) { + final List chars = entity.getCurrentSceneCharacters().stream() + .map(ch -> new CharacterDto().name(ch.getName()).role(ch.getRole()).description(ch.getDescription())) + .toList(); + return new Story() + .id(entity.getId()) + .title(entity.getTitle()) + .scenarioId(entity.getScenarioId()) + .currentSceneDescription(entity.getCurrentSceneDescription()) + .currentSceneCharacters(chars) + .status(Story.StatusEnum.fromValue(entity.getStatus())); + } + + /** Maps a StoryStepEntity to the API StoryStep DTO. */ + private StoryStep toStepDto(final StoryStepEntity entity) { + return new StoryStep() + .id(entity.getId()) + .stepNumber(entity.getStepNumber()) + .content(entity.getContent()) + .userDirection(entity.getUserDirection()) + .addedToScene(entity.isAddedToScene()); + } +} diff --git a/src/main/java/de/neitzel/storyteller/business/UserService.java b/src/main/java/de/neitzel/storyteller/business/UserService.java new file mode 100644 index 0000000..b394a88 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/business/UserService.java @@ -0,0 +1,130 @@ +package de.neitzel.storyteller.business; + +import de.neitzel.storyteller.common.security.PasswordHasher; +import de.neitzel.storyteller.data.entity.UserEntity; +import de.neitzel.storyteller.data.repository.UserRepository; +import de.neitzel.storyteller.fascade.model.CreateUserRequest; +import de.neitzel.storyteller.fascade.model.UpdateUserRequest; +import de.neitzel.storyteller.fascade.model.User; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Admin-only user management: create, list, update, and delete users. + */ +@ApplicationScoped +public class UserService { + + /** User persistence. */ + private final UserRepository userRepository; + + /** Password hashing utility. */ + private final PasswordHasher passwordHasher; + + /** + * Constructs the service with required dependencies. + * + * @param userRepository user persistence + * @param passwordHasher BCrypt hasher + */ + public UserService(final UserRepository userRepository, + final PasswordHasher passwordHasher) { + this.userRepository = userRepository; + this.passwordHasher = passwordHasher; + } + + /** + * Lists all users (admin-only). + * + * @return all users mapped to API DTOs + */ + public List listUsers() { + return userRepository.listAll().stream() + .map(this::toDto) + .toList(); + } + + /** + * Creates a new user (admin-only). + * + * @param request creation payload + * @return the newly created user DTO + */ + @Transactional + public User createUser(final CreateUserRequest request) { + if (userRepository.findByUsername(request.getUsername()).isPresent()) { + throw new WebApplicationException("Username already taken", Response.Status.CONFLICT); + } + final UserEntity entity = new UserEntity(); + entity.setUsername(request.getUsername()); + entity.setPasswordHash(passwordHasher.hash(request.getPassword())); + entity.setAdmin(Boolean.TRUE.equals(request.getIsAdmin())); + entity.setActive(true); + entity.setCreatedAt(LocalDateTime.now()); + entity.setUpdatedAt(LocalDateTime.now()); + userRepository.persist(entity); + return toDto(entity); + } + + /** + * Updates an existing user (admin-only). + * + * @param userId the user to update + * @param request fields to change + * @return updated user DTO + */ + @Transactional + public User updateUser(final Long userId, final UpdateUserRequest request) { + final UserEntity entity = userRepository.findById(userId); + if (entity == null) { + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND); + } + if (request.getUsername() != null) { + entity.setUsername(request.getUsername()); + } + if (request.getPassword() != null) { + entity.setPasswordHash(passwordHasher.hash(request.getPassword())); + } + if (request.getIsAdmin() != null) { + entity.setAdmin(request.getIsAdmin()); + } + if (request.getActive() != null) { + entity.setActive(request.getActive()); + } + entity.setUpdatedAt(LocalDateTime.now()); + return toDto(entity); + } + + /** + * Deletes a user (admin-only, soft delete by deactivation). + * + * @param userId user to delete + */ + @Transactional + public void deleteUser(final Long userId) { + final UserEntity entity = userRepository.findById(userId); + if (entity == null) { + throw new WebApplicationException("User not found", Response.Status.NOT_FOUND); + } + entity.setActive(false); + entity.setUpdatedAt(LocalDateTime.now()); + } + + /** + * Maps a persistence entity to the API DTO. + * + * @param entity JPA entity + * @return API model + */ + private User toDto(final UserEntity entity) { + return new User() + .id(entity.getId()) + .username(entity.getUsername()) + .isAdmin(entity.isAdmin()) + .active(entity.isActive()); + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java b/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java new file mode 100644 index 0000000..4d66963 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/security/AuthContext.java @@ -0,0 +1,43 @@ +package de.neitzel.storyteller.common.security; + +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.jwt.JsonWebToken; + +/** + * Request-scoped helper that extracts identity information from the current JWT. + */ +@RequestScoped +public class AuthContext { + + /** The injected JWT token for the current request. */ + @Inject + JsonWebToken jwt; + + /** + * Returns the authenticated user's database id. + * + * @return user id from the JWT {@code userId} claim + */ + public Long getUserId() { + return jwt.getClaim(JwtService.CLAIM_USER_ID); + } + + /** + * Returns the authenticated user's login name (JWT subject). + * + * @return username + */ + public String getUsername() { + return jwt.getSubject(); + } + + /** + * Returns whether the current user has the admin role. + * + * @return true if admin + */ + public boolean isAdmin() { + return jwt.getGroups().contains("admin"); + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/security/JwtService.java b/src/main/java/de/neitzel/storyteller/common/security/JwtService.java new file mode 100644 index 0000000..c050fd7 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/security/JwtService.java @@ -0,0 +1,32 @@ +package de.neitzel.storyteller.common.security; + +import io.smallrye.jwt.build.Jwt; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Set; + +/** + * Builds signed JWT tokens for authenticated users. + */ +@ApplicationScoped +public class JwtService { + + /** Claim name for the user's database id. */ + public static final String CLAIM_USER_ID = "userId"; + + /** + * Creates a signed JWT for the given user. + * + * @param userId database id + * @param username login name used as the token subject + * @param isAdmin whether the user has admin role + * @return signed JWT string + */ + public String generateToken(final Long userId, final String username, final boolean isAdmin) { + final Set groups = isAdmin ? Set.of("admin", "user") : Set.of("user"); + return Jwt.issuer("storyteller") + .subject(username) + .groups(groups) + .claim(CLAIM_USER_ID, userId) + .sign(); + } +} diff --git a/src/main/java/de/neitzel/storyteller/common/security/PasswordHasher.java b/src/main/java/de/neitzel/storyteller/common/security/PasswordHasher.java new file mode 100644 index 0000000..9eef2ee --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/common/security/PasswordHasher.java @@ -0,0 +1,32 @@ +package de.neitzel.storyteller.common.security; + +import jakarta.enterprise.context.ApplicationScoped; +import org.mindrot.jbcrypt.BCrypt; + +/** + * Provides password hashing and verification using BCrypt. + */ +@ApplicationScoped +public class PasswordHasher { + + /** + * Hashes a plain-text password with BCrypt. + * + * @param plainText the raw password + * @return BCrypt hash string + */ + public String hash(final String plainText) { + return BCrypt.hashpw(plainText, BCrypt.gensalt()); + } + + /** + * Verifies a plain-text password against a stored BCrypt hash. + * + * @param plainText the raw password + * @param storedHash the stored BCrypt hash + * @return true if the password matches + */ + public boolean verify(final String plainText, final String storedHash) { + return BCrypt.checkpw(plainText, storedHash); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/ScenarioCharacterEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/ScenarioCharacterEntity.java new file mode 100644 index 0000000..3dd877b --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/ScenarioCharacterEntity.java @@ -0,0 +1,62 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * JPA entity representing a character within a scenario's starting set. + */ +@Entity +@Table(name = "scenario_character") +public class ScenarioCharacterEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Parent scenario id. */ + @Column(name = "scenario_id", nullable = false) + private Long scenarioId; + + /** Character name. */ + @Column(nullable = false) + private String name; + + /** Character role. */ + @Column(nullable = false) + private String role; + + /** Character description. */ + @Column(columnDefinition = "TEXT") + private String description; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return parent scenario id */ + public Long getScenarioId() { return scenarioId; } + /** @param scenarioId parent scenario id */ + public void setScenarioId(final Long scenarioId) { this.scenarioId = scenarioId; } + + /** @return name */ + public String getName() { return name; } + /** @param name character name */ + public void setName(final String name) { this.name = name; } + + /** @return role */ + public String getRole() { return role; } + /** @param role character role */ + public void setRole(final String role) { this.role = role; } + + /** @return description */ + public String getDescription() { return description; } + /** @param description character description */ + public void setDescription(final String description) { this.description = description; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/ScenarioEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/ScenarioEntity.java new file mode 100644 index 0000000..44dd411 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/ScenarioEntity.java @@ -0,0 +1,95 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * JPA entity representing a scenario (starting point for stories). + */ +@Entity +@Table(name = "scenario") +public class ScenarioEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Owning user id. */ + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + /** Scenario title. */ + @Column(nullable = false) + private String title; + + /** Optional overall description. */ + @Column(columnDefinition = "TEXT") + private String description; + + /** Initial scene narrative text. */ + @Column(name = "starting_scene_description", nullable = false, columnDefinition = "TEXT") + private String startingSceneDescription; + + /** Starting characters for this scenario. */ + @OneToMany(mappedBy = "scenarioId", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List startingCharacters = new ArrayList<>(); + + /** Row creation timestamp. */ + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** Row last-update timestamp. */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return owning user id */ + public Long getOwnerUserId() { return ownerUserId; } + /** @param ownerUserId owning user id */ + public void setOwnerUserId(final Long ownerUserId) { this.ownerUserId = ownerUserId; } + + /** @return title */ + public String getTitle() { return title; } + /** @param title scenario title */ + public void setTitle(final String title) { this.title = title; } + + /** @return description */ + public String getDescription() { return description; } + /** @param description overall description */ + public void setDescription(final String description) { this.description = description; } + + /** @return starting scene narrative */ + public String getStartingSceneDescription() { return startingSceneDescription; } + /** @param startingSceneDescription starting scene narrative */ + public void setStartingSceneDescription(final String startingSceneDescription) { this.startingSceneDescription = startingSceneDescription; } + + /** @return starting characters */ + public List getStartingCharacters() { return startingCharacters; } + /** @param startingCharacters starting characters */ + public void setStartingCharacters(final List startingCharacters) { this.startingCharacters = startingCharacters; } + + /** @return creation time */ + public LocalDateTime getCreatedAt() { return createdAt; } + /** @param createdAt creation time */ + public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; } + + /** @return last update time */ + public LocalDateTime getUpdatedAt() { return updatedAt; } + /** @param updatedAt last update time */ + public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/StoryCharacterEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/StoryCharacterEntity.java new file mode 100644 index 0000000..fabe3a4 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/StoryCharacterEntity.java @@ -0,0 +1,62 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * JPA entity representing a character within a story's current scene. + */ +@Entity +@Table(name = "story_character") +public class StoryCharacterEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Parent story id. */ + @Column(name = "story_id", nullable = false) + private Long storyId; + + /** Character name. */ + @Column(nullable = false) + private String name; + + /** Character role (e.g. protagonist, villain). */ + @Column(nullable = false) + private String role; + + /** Character description. */ + @Column(columnDefinition = "TEXT") + private String description; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return parent story id */ + public Long getStoryId() { return storyId; } + /** @param storyId parent story id */ + public void setStoryId(final Long storyId) { this.storyId = storyId; } + + /** @return name */ + public String getName() { return name; } + /** @param name character name */ + public void setName(final String name) { this.name = name; } + + /** @return role */ + public String getRole() { return role; } + /** @param role character role */ + public void setRole(final String role) { this.role = role; } + + /** @return description */ + public String getDescription() { return description; } + /** @param description character description */ + public void setDescription(final String description) { this.description = description; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/StoryEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/StoryEntity.java new file mode 100644 index 0000000..64babc8 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/StoryEntity.java @@ -0,0 +1,104 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * JPA entity representing a story created from a scenario. + */ +@Entity +@Table(name = "story") +public class StoryEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Owning user id. */ + @Column(name = "owner_user_id", nullable = false) + private Long ownerUserId; + + /** Originating scenario id. */ + @Column(name = "scenario_id", nullable = false) + private Long scenarioId; + + /** Story title. */ + @Column(nullable = false) + private String title; + + /** Current compacted scene narrative. */ + @Column(name = "current_scene_description", nullable = false, columnDefinition = "TEXT") + private String currentSceneDescription; + + /** Lifecycle status: ACTIVE, ARCHIVED, DELETED. */ + @Column(nullable = false, length = 32) + private String status; + + /** Current scene characters. */ + @OneToMany(mappedBy = "storyId", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) + private List currentSceneCharacters = new ArrayList<>(); + + /** Row creation timestamp. */ + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** Row last-update timestamp. */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return owning user id */ + public Long getOwnerUserId() { return ownerUserId; } + /** @param ownerUserId owning user id */ + public void setOwnerUserId(final Long ownerUserId) { this.ownerUserId = ownerUserId; } + + /** @return originating scenario id */ + public Long getScenarioId() { return scenarioId; } + /** @param scenarioId scenario id */ + public void setScenarioId(final Long scenarioId) { this.scenarioId = scenarioId; } + + /** @return title */ + public String getTitle() { return title; } + /** @param title story title */ + public void setTitle(final String title) { this.title = title; } + + /** @return current scene narrative */ + public String getCurrentSceneDescription() { return currentSceneDescription; } + /** @param currentSceneDescription scene narrative */ + public void setCurrentSceneDescription(final String currentSceneDescription) { this.currentSceneDescription = currentSceneDescription; } + + /** @return lifecycle status */ + public String getStatus() { return status; } + /** @param status lifecycle status */ + public void setStatus(final String status) { this.status = status; } + + /** @return scene characters */ + public List getCurrentSceneCharacters() { return currentSceneCharacters; } + /** @param currentSceneCharacters scene characters */ + public void setCurrentSceneCharacters(final List currentSceneCharacters) { this.currentSceneCharacters = currentSceneCharacters; } + + /** @return creation time */ + public LocalDateTime getCreatedAt() { return createdAt; } + /** @param createdAt creation time */ + public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; } + + /** @return last update time */ + public LocalDateTime getUpdatedAt() { return updatedAt; } + /** @param updatedAt last update time */ + public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/StoryStepEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/StoryStepEntity.java new file mode 100644 index 0000000..8cd6123 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/StoryStepEntity.java @@ -0,0 +1,81 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +/** + * JPA entity representing a single step in a story's progression. + */ +@Entity +@Table(name = "story_step") +public class StoryStepEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Parent story id. */ + @Column(name = "story_id", nullable = false) + private Long storyId; + + /** Monotonically increasing step number within a story. */ + @Column(name = "step_number", nullable = false) + private int stepNumber; + + /** Narrative text for this step. */ + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + /** The user's direction prompt that produced this step. */ + @Column(name = "user_direction", columnDefinition = "TEXT") + private String userDirection; + + /** Whether this step has been compacted into the current scene. */ + @Column(name = "added_to_scene", nullable = false) + private boolean addedToScene; + + /** Row creation timestamp. */ + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return parent story id */ + public Long getStoryId() { return storyId; } + /** @param storyId parent story id */ + public void setStoryId(final Long storyId) { this.storyId = storyId; } + + /** @return step number */ + public int getStepNumber() { return stepNumber; } + /** @param stepNumber step number */ + public void setStepNumber(final int stepNumber) { this.stepNumber = stepNumber; } + + /** @return narrative content */ + public String getContent() { return content; } + /** @param content narrative content */ + public void setContent(final String content) { this.content = content; } + + /** @return user's direction prompt */ + public String getUserDirection() { return userDirection; } + /** @param userDirection user's direction prompt */ + public void setUserDirection(final String userDirection) { this.userDirection = userDirection; } + + /** @return true if compacted into scene */ + public boolean isAddedToScene() { return addedToScene; } + /** @param addedToScene compacted flag */ + public void setAddedToScene(final boolean addedToScene) { this.addedToScene = addedToScene; } + + /** @return creation time */ + public LocalDateTime getCreatedAt() { return createdAt; } + /** @param createdAt creation time */ + public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/entity/UserEntity.java b/src/main/java/de/neitzel/storyteller/data/entity/UserEntity.java new file mode 100644 index 0000000..dff471b --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/entity/UserEntity.java @@ -0,0 +1,81 @@ +package de.neitzel.storyteller.data.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +/** + * JPA entity representing an application user. + */ +@Entity +@Table(name = "app_user") +public class UserEntity { + + /** Primary key. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** Unique login name. */ + @Column(nullable = false, unique = true, length = 128) + private String username; + + /** BCrypt password hash. */ + @Column(name = "password_hash", nullable = false) + private String passwordHash; + + /** Whether the user has admin privileges. */ + @Column(name = "is_admin", nullable = false) + private boolean admin; + + /** Whether the account is active. */ + @Column(nullable = false) + private boolean active; + + /** Row creation timestamp. */ + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + /** Row last-update timestamp. */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** @return primary key */ + public Long getId() { return id; } + /** @param id primary key */ + public void setId(final Long id) { this.id = id; } + + /** @return login name */ + public String getUsername() { return username; } + /** @param username login name */ + public void setUsername(final String username) { this.username = username; } + + /** @return BCrypt hash */ + public String getPasswordHash() { return passwordHash; } + /** @param passwordHash BCrypt hash */ + public void setPasswordHash(final String passwordHash) { this.passwordHash = passwordHash; } + + /** @return true if admin */ + public boolean isAdmin() { return admin; } + /** @param admin admin flag */ + public void setAdmin(final boolean admin) { this.admin = admin; } + + /** @return true if active */ + public boolean isActive() { return active; } + /** @param active active flag */ + public void setActive(final boolean active) { this.active = active; } + + /** @return creation time */ + public LocalDateTime getCreatedAt() { return createdAt; } + /** @param createdAt creation time */ + public void setCreatedAt(final LocalDateTime createdAt) { this.createdAt = createdAt; } + + /** @return last update time */ + public LocalDateTime getUpdatedAt() { return updatedAt; } + /** @param updatedAt last update time */ + public void setUpdatedAt(final LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/ScenarioCharacterRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/ScenarioCharacterRepository.java new file mode 100644 index 0000000..2c495e3 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/ScenarioCharacterRepository.java @@ -0,0 +1,33 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.ScenarioCharacterEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Panache repository for {@link ScenarioCharacterEntity} persistence operations. + */ +@ApplicationScoped +public class ScenarioCharacterRepository implements PanacheRepository { + + /** + * Returns all characters belonging to a scenario. + * + * @param scenarioId the scenario id + * @return list of characters + */ + public List findByScenario(final Long scenarioId) { + return list("scenarioId", scenarioId); + } + + /** + * Deletes all characters for a scenario. + * + * @param scenarioId the scenario id + * @return number of deleted rows + */ + public long deleteByScenario(final Long scenarioId) { + return delete("scenarioId", scenarioId); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/ScenarioRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/ScenarioRepository.java new file mode 100644 index 0000000..edb918f --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/ScenarioRepository.java @@ -0,0 +1,23 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.ScenarioEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Panache repository for {@link ScenarioEntity} persistence operations. + */ +@ApplicationScoped +public class ScenarioRepository implements PanacheRepository { + + /** + * Returns all scenarios owned by a specific user. + * + * @param ownerUserId the owning user's id + * @return list of scenarios + */ + public List findByOwner(final Long ownerUserId) { + return list("ownerUserId", ownerUserId); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/StoryCharacterRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/StoryCharacterRepository.java new file mode 100644 index 0000000..6038198 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/StoryCharacterRepository.java @@ -0,0 +1,33 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.StoryCharacterEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Panache repository for {@link StoryCharacterEntity} persistence operations. + */ +@ApplicationScoped +public class StoryCharacterRepository implements PanacheRepository { + + /** + * Returns all characters belonging to a story. + * + * @param storyId the story id + * @return list of characters + */ + public List findByStory(final Long storyId) { + return list("storyId", storyId); + } + + /** + * Deletes all characters for a story. + * + * @param storyId the story id + * @return number of deleted rows + */ + public long deleteByStory(final Long storyId) { + return delete("storyId", storyId); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/StoryRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/StoryRepository.java new file mode 100644 index 0000000..83ec35d --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/StoryRepository.java @@ -0,0 +1,23 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.StoryEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Panache repository for {@link StoryEntity} persistence operations. + */ +@ApplicationScoped +public class StoryRepository implements PanacheRepository { + + /** + * Returns all non-deleted stories owned by a specific user. + * + * @param ownerUserId the owning user's id + * @return list of stories + */ + public List findByOwner(final Long ownerUserId) { + return list("ownerUserId = ?1 and status != 'DELETED'", ownerUserId); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java new file mode 100644 index 0000000..870d6ae --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/StoryStepRepository.java @@ -0,0 +1,57 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.StoryStepEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * Panache repository for {@link StoryStepEntity} persistence operations. + */ +@ApplicationScoped +public class StoryStepRepository implements PanacheRepository { + + /** + * Returns all steps for a story ordered by step number ascending. + * + * @param storyId the story id + * @return ordered list of steps + */ + public List findByStoryOrdered(final Long storyId) { + return list("storyId = ?1 order by stepNumber asc", storyId); + } + + /** + * Returns only the unmerged steps for a story ordered by step number ascending. + * + * @param storyId the story id + * @return ordered list of unmerged steps + */ + public List findUnmergedByStory(final Long storyId) { + return list("storyId = ?1 and addedToScene = false order by stepNumber asc", storyId); + } + + /** + * Deletes all steps for a story. + * + * @param storyId the story id + * @return number of deleted rows + */ + public long deleteByStory(final Long storyId) { + return delete("storyId", storyId); + } + + /** + * Returns the maximum step number for a story, or 0 if no steps exist. + * + * @param storyId the story id + * @return max step number + */ + public int maxStepNumber(final Long storyId) { + Long result = getEntityManager() + .createQuery("select coalesce(max(s.stepNumber),0) from StoryStepEntity s where s.storyId = :sid", Long.class) + .setParameter("sid", storyId) + .getSingleResult(); + return result.intValue(); + } +} diff --git a/src/main/java/de/neitzel/storyteller/data/repository/UserRepository.java b/src/main/java/de/neitzel/storyteller/data/repository/UserRepository.java new file mode 100644 index 0000000..092ceb4 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/data/repository/UserRepository.java @@ -0,0 +1,23 @@ +package de.neitzel.storyteller.data.repository; + +import de.neitzel.storyteller.data.entity.UserEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepository; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; + +/** + * Panache repository for {@link UserEntity} persistence operations. + */ +@ApplicationScoped +public class UserRepository implements PanacheRepository { + + /** + * Finds a user by unique username. + * + * @param username the login name + * @return optional containing the user if found + */ + public Optional findByUsername(final String username) { + return find("username", username).firstResultOptional(); + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/AuthResource.java b/src/main/java/de/neitzel/storyteller/fascade/rest/AuthResource.java new file mode 100644 index 0000000..88eb870 --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/AuthResource.java @@ -0,0 +1,34 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.business.AuthService; +import de.neitzel.storyteller.fascade.api.AuthApi; +import de.neitzel.storyteller.fascade.model.LoginRequest; +import de.neitzel.storyteller.fascade.model.LoginResponse; +import jakarta.annotation.security.PermitAll; +import jakarta.enterprise.context.ApplicationScoped; + +/** + * REST facade for authentication. This endpoint is publicly accessible. + */ +@ApplicationScoped +@PermitAll +public class AuthResource implements AuthApi { + + /** Authentication service delegate. */ + private final AuthService authService; + + /** + * Constructs the resource with service dependency. + * + * @param authService auth service + */ + public AuthResource(final AuthService authService) { + this.authService = authService; + } + + /** {@inheritDoc} */ + @Override + public LoginResponse login(final LoginRequest loginRequest) { + return authService.login(loginRequest); + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/ScenariosResource.java b/src/main/java/de/neitzel/storyteller/fascade/rest/ScenariosResource.java new file mode 100644 index 0000000..05631fa --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/ScenariosResource.java @@ -0,0 +1,68 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.business.ScenarioService; +import de.neitzel.storyteller.common.security.AuthContext; +import de.neitzel.storyteller.fascade.api.ScenariosApi; +import de.neitzel.storyteller.fascade.model.CreateScenarioRequest; +import de.neitzel.storyteller.fascade.model.Scenario; +import de.neitzel.storyteller.fascade.model.UpdateScenarioRequest; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * REST facade for scenario management. Available to all authenticated users. + */ +@ApplicationScoped +@RolesAllowed({"user", "admin"}) +public class ScenariosResource implements ScenariosApi { + + /** Scenario service delegate. */ + private final ScenarioService scenarioService; + + /** Request-scoped auth context. */ + private final AuthContext authContext; + + /** + * Constructs the resource with dependencies. + * + * @param scenarioService scenario service + * @param authContext auth context + */ + public ScenariosResource(final ScenarioService scenarioService, + final AuthContext authContext) { + this.scenarioService = scenarioService; + this.authContext = authContext; + } + + /** {@inheritDoc} */ + @Override + public Scenario createScenario(final CreateScenarioRequest createScenarioRequest) { + return scenarioService.createScenario(createScenarioRequest, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public void deleteScenario(final Long scenarioId) { + scenarioService.deleteScenario(scenarioId, authContext.getUserId(), authContext.isAdmin()); + } + + /** {@inheritDoc} */ + @Override + public Scenario getScenario(final Long scenarioId) { + return scenarioService.getScenario(scenarioId, authContext.getUserId(), authContext.isAdmin()); + } + + /** {@inheritDoc} */ + @Override + public List listScenarios() { + return scenarioService.listScenarios(authContext.getUserId(), authContext.isAdmin()); + } + + /** {@inheritDoc} */ + @Override + public Scenario updateScenario(final Long scenarioId, final UpdateScenarioRequest updateScenarioRequest) { + return scenarioService.updateScenario(scenarioId, updateScenarioRequest, + authContext.getUserId(), authContext.isAdmin()); + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/StoriesResource.java b/src/main/java/de/neitzel/storyteller/fascade/rest/StoriesResource.java new file mode 100644 index 0000000..f9fc00f --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/StoriesResource.java @@ -0,0 +1,91 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.business.StoryService; +import de.neitzel.storyteller.common.security.AuthContext; +import de.neitzel.storyteller.fascade.api.StoriesApi; +import de.neitzel.storyteller.fascade.model.GenerateStepRequest; +import de.neitzel.storyteller.fascade.model.GenerateStepResponse; +import de.neitzel.storyteller.fascade.model.StartStoryRequest; +import de.neitzel.storyteller.fascade.model.Story; +import de.neitzel.storyteller.fascade.model.StoryStep; +import de.neitzel.storyteller.fascade.model.UpdateStepRequest; +import de.neitzel.storyteller.fascade.model.UpdateStoryRequest; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * REST facade for story and story-step operations. Available to all authenticated users. + */ +@ApplicationScoped +@RolesAllowed({"user", "admin"}) +public class StoriesResource implements StoriesApi { + + /** Story service delegate. */ + private final StoryService storyService; + + /** Request-scoped auth context. */ + private final AuthContext authContext; + + /** + * Constructs the resource with dependencies. + * + * @param storyService story service + * @param authContext auth context + */ + public StoriesResource(final StoryService storyService, + final AuthContext authContext) { + this.storyService = storyService; + this.authContext = authContext; + } + + /** {@inheritDoc} */ + @Override + public void deleteStory(final Long storyId) { + storyService.deleteStory(storyId, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public GenerateStepResponse generateStoryStep(final Long storyId, + final GenerateStepRequest generateStepRequest) { + return storyService.generateStoryStep(storyId, generateStepRequest, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public Story getStory(final Long storyId) { + return storyService.getStory(storyId, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public List listStories() { + return storyService.listStories(authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public List listStorySteps(final Long storyId) { + return storyService.listStorySteps(storyId, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public Story startStory(final StartStoryRequest startStoryRequest) { + return storyService.startStory(startStoryRequest, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public Story updateStory(final Long storyId, final UpdateStoryRequest updateStoryRequest) { + return storyService.updateStory(storyId, updateStoryRequest, authContext.getUserId()); + } + + /** {@inheritDoc} */ + @Override + public StoryStep updateStoryStep(final Long storyId, final Long stepId, + final UpdateStepRequest updateStepRequest) { + return storyService.updateStoryStep(storyId, stepId, updateStepRequest, authContext.getUserId()); + } +} diff --git a/src/main/java/de/neitzel/storyteller/fascade/rest/UsersResource.java b/src/main/java/de/neitzel/storyteller/fascade/rest/UsersResource.java new file mode 100644 index 0000000..86e7a0c --- /dev/null +++ b/src/main/java/de/neitzel/storyteller/fascade/rest/UsersResource.java @@ -0,0 +1,54 @@ +package de.neitzel.storyteller.fascade.rest; + +import de.neitzel.storyteller.business.UserService; +import de.neitzel.storyteller.fascade.api.UsersApi; +import de.neitzel.storyteller.fascade.model.CreateUserRequest; +import de.neitzel.storyteller.fascade.model.UpdateUserRequest; +import de.neitzel.storyteller.fascade.model.User; +import jakarta.annotation.security.RolesAllowed; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; + +/** + * REST facade for admin-only user management endpoints. + */ +@ApplicationScoped +@RolesAllowed("admin") +public class UsersResource implements UsersApi { + + /** User service delegate. */ + private final UserService userService; + + /** + * Constructs the resource with service dependency. + * + * @param userService user service + */ + public UsersResource(final UserService userService) { + this.userService = userService; + } + + /** {@inheritDoc} */ + @Override + public User createUser(final CreateUserRequest createUserRequest) { + return userService.createUser(createUserRequest); + } + + /** {@inheritDoc} */ + @Override + public void deleteUser(final Long userId) { + userService.deleteUser(userId); + } + + /** {@inheritDoc} */ + @Override + public List listUsers() { + return userService.listUsers(); + } + + /** {@inheritDoc} */ + @Override + public User updateUser(final Long userId, final UpdateUserRequest updateUserRequest) { + return userService.updateUser(userId, updateUserRequest); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..5968fa7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,25 @@ +quarkus.http.port=8080 +quarkus.http.cors=true + +quarkus.liquibase.migrate-at-start=true +quarkus.liquibase.change-log=db/migration/db.changelog-master.yaml + +quarkus.datasource.db-kind=postgresql +quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/storyteller +quarkus.datasource.username=storyteller +quarkus.datasource.password=storyteller + +quarkus.hibernate-orm.database.generation=none + +mp.jwt.verify.publickey.location=publicKey.pem +mp.jwt.verify.issuer=storyteller +smallrye.jwt.sign.key.location=privateKey.pem +smallrye.jwt.new-token.lifespan=86400 +smallrye.jwt.new-token.issuer=storyteller + +quarkus.http.auth.permission.public.paths=/api/auth/* +quarkus.http.auth.permission.public.policy=permit +quarkus.http.auth.permission.protected.paths=/api/* +quarkus.http.auth.permission.protected.policy=authenticated +quarkus.http.auth.permission.static.paths=/* +quarkus.http.auth.permission.static.policy=permit diff --git a/src/main/resources/db/migration/db.changelog-master.yaml b/src/main/resources/db/migration/db.changelog-master.yaml new file mode 100644 index 0000000..764068a --- /dev/null +++ b/src/main/resources/db/migration/db.changelog-master.yaml @@ -0,0 +1,281 @@ +databaseChangeLog: + - changeSet: + id: 001-create-users + author: storyteller + changes: + - createTable: + tableName: app_user + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: username + type: VARCHAR(128) + constraints: + nullable: false + unique: true + - column: + name: password_hash + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: is_admin + type: BOOLEAN + defaultValueBoolean: false + constraints: + nullable: false + - column: + name: active + type: BOOLEAN + defaultValueBoolean: true + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + constraints: + nullable: false + + - changeSet: + id: 002-create-scenarios + author: storyteller + changes: + - createTable: + tableName: scenario + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: owner_user_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_scenario_owner + references: app_user(id) + - column: + name: title + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: description + type: TEXT + - column: + name: starting_scene_description + type: TEXT + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + constraints: + nullable: false + - createTable: + tableName: scenario_character + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: scenario_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_scenario_character_scenario + references: scenario(id) + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: role + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: description + type: TEXT + + - changeSet: + id: 003-create-stories + author: storyteller + changes: + - createTable: + tableName: story + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: owner_user_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_story_owner + references: app_user(id) + - column: + name: scenario_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_story_scenario + references: scenario(id) + - column: + name: title + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: current_scene_description + type: TEXT + constraints: + nullable: false + - column: + name: status + type: VARCHAR(32) + defaultValue: ACTIVE + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: TIMESTAMP + constraints: + nullable: false + - createTable: + tableName: story_character + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: story_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_story_character_story + references: story(id) + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: role + type: VARCHAR(255) + constraints: + nullable: false + - column: + name: description + type: TEXT + + - changeSet: + id: 004-create-story-steps + author: storyteller + changes: + - createTable: + tableName: story_step + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: story_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_story_step_story + references: story(id) + - column: + name: step_number + type: INTEGER + constraints: + nullable: false + - column: + name: content + type: TEXT + constraints: + nullable: false + - column: + name: user_direction + type: TEXT + - column: + name: added_to_scene + type: BOOLEAN + defaultValueBoolean: false + constraints: + nullable: false + - column: + name: created_at + type: TIMESTAMP + constraints: + nullable: false + + - changeSet: + id: 005-seed-admin + author: storyteller + comment: Seed initial admin user (password is admin, BCrypt hash) + changes: + - insert: + tableName: app_user + columns: + - column: + name: username + value: admin + - column: + name: password_hash + value: $2a$10$e7j3j8GSHRMfwA8rg7j6DeanhTxEC8qtlcOhq7DNM/f6/Rli2arFe + - column: + name: is_admin + valueBoolean: true + - column: + name: active + valueBoolean: true + - column: + name: created_at + valueComputed: CURRENT_TIMESTAMP + - column: + name: updated_at + valueComputed: CURRENT_TIMESTAMP diff --git a/src/main/resources/openapi/story-teller-api.yaml b/src/main/resources/openapi/story-teller-api.yaml new file mode 100644 index 0000000..1fe732a --- /dev/null +++ b/src/main/resources/openapi/story-teller-api.yaml @@ -0,0 +1,506 @@ +openapi: 3.0.3 +info: + title: StoryTeller API + version: 1.0.0 + description: OpenAPI-first API for StoryTeller. +servers: + - url: /api +tags: + - name: Auth + - name: Users + - name: Scenarios + - name: Stories +paths: + /auth/login: + post: + tags: [Auth] + operationId: login + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + responses: + '200': + description: Login successful. + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + /users: + get: + tags: [Users] + operationId: listUsers + responses: + '200': + description: List users. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + post: + tags: [Users] + operationId: createUser + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: User created. + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /users/{userId}: + put: + tags: [Users] + operationId: updateUser + parameters: + - $ref: '#/components/parameters/UserId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: User updated. + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + tags: [Users] + operationId: deleteUser + parameters: + - $ref: '#/components/parameters/UserId' + responses: + '204': + description: User deleted. + /scenarios: + get: + tags: [Scenarios] + operationId: listScenarios + responses: + '200': + description: List scenarios for the authenticated user. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Scenario' + post: + tags: [Scenarios] + operationId: createScenario + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScenarioRequest' + responses: + '201': + description: Scenario created. + content: + application/json: + schema: + $ref: '#/components/schemas/Scenario' + /scenarios/{scenarioId}: + get: + tags: [Scenarios] + operationId: getScenario + parameters: + - $ref: '#/components/parameters/ScenarioId' + responses: + '200': + description: Get scenario. + content: + application/json: + schema: + $ref: '#/components/schemas/Scenario' + put: + tags: [Scenarios] + operationId: updateScenario + parameters: + - $ref: '#/components/parameters/ScenarioId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateScenarioRequest' + responses: + '200': + description: Scenario updated. + content: + application/json: + schema: + $ref: '#/components/schemas/Scenario' + delete: + tags: [Scenarios] + operationId: deleteScenario + parameters: + - $ref: '#/components/parameters/ScenarioId' + responses: + '204': + description: Scenario deleted. + /stories: + get: + tags: [Stories] + operationId: listStories + responses: + '200': + description: List stories for the authenticated user. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Story' + post: + tags: [Stories] + operationId: startStory + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StartStoryRequest' + responses: + '201': + description: Story created. + content: + application/json: + schema: + $ref: '#/components/schemas/Story' + /stories/{storyId}: + get: + tags: [Stories] + operationId: getStory + parameters: + - $ref: '#/components/parameters/StoryId' + responses: + '200': + description: Get story. + content: + application/json: + schema: + $ref: '#/components/schemas/Story' + put: + tags: [Stories] + operationId: updateStory + parameters: + - $ref: '#/components/parameters/StoryId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStoryRequest' + responses: + '200': + description: Story updated. + content: + application/json: + schema: + $ref: '#/components/schemas/Story' + delete: + tags: [Stories] + operationId: deleteStory + parameters: + - $ref: '#/components/parameters/StoryId' + responses: + '204': + description: Story deleted. + /stories/{storyId}/steps: + get: + tags: [Stories] + operationId: listStorySteps + parameters: + - $ref: '#/components/parameters/StoryId' + responses: + '200': + description: List story steps. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StoryStep' + /stories/{storyId}/steps/{stepId}: + put: + tags: [Stories] + operationId: updateStoryStep + parameters: + - $ref: '#/components/parameters/StoryId' + - $ref: '#/components/parameters/StepId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateStepRequest' + responses: + '200': + description: Step updated. + content: + application/json: + schema: + $ref: '#/components/schemas/StoryStep' + /stories/{storyId}/generate-step: + post: + tags: [Stories] + operationId: generateStoryStep + parameters: + - $ref: '#/components/parameters/StoryId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateStepRequest' + responses: + '200': + description: Next step generated. + content: + application/json: + schema: + $ref: '#/components/schemas/GenerateStepResponse' +components: + parameters: + UserId: + in: path + name: userId + required: true + schema: + type: integer + format: int64 + ScenarioId: + in: path + name: scenarioId + required: true + schema: + type: integer + format: int64 + StoryId: + in: path + name: storyId + required: true + schema: + type: integer + format: int64 + StepId: + in: path + name: stepId + required: true + schema: + type: integer + format: int64 + schemas: + LoginRequest: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + LoginResponse: + type: object + required: [token, userId, username, isAdmin] + properties: + token: + type: string + userId: + type: integer + format: int64 + username: + type: string + isAdmin: + type: boolean + User: + type: object + required: [id, username, isAdmin, active] + properties: + id: + type: integer + format: int64 + username: + type: string + isAdmin: + type: boolean + active: + type: boolean + CreateUserRequest: + type: object + required: [username, password, isAdmin] + properties: + username: + type: string + password: + type: string + isAdmin: + type: boolean + UpdateUserRequest: + type: object + properties: + username: + type: string + password: + type: string + isAdmin: + type: boolean + active: + type: boolean + CharacterDto: + type: object + required: [name, role, description] + properties: + name: + type: string + role: + type: string + description: + type: string + Scenario: + type: object + required: [id, title, startingSceneDescription, startingCharacters] + properties: + id: + type: integer + format: int64 + title: + type: string + description: + type: string + startingSceneDescription: + type: string + startingCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + CreateScenarioRequest: + type: object + required: [title, startingSceneDescription, startingCharacters] + properties: + title: + type: string + description: + type: string + startingSceneDescription: + type: string + startingCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + UpdateScenarioRequest: + type: object + properties: + title: + type: string + description: + type: string + startingSceneDescription: + type: string + startingCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + Story: + type: object + required: [id, title, scenarioId, currentSceneDescription, currentSceneCharacters, status] + properties: + id: + type: integer + format: int64 + title: + type: string + scenarioId: + type: integer + format: int64 + currentSceneDescription: + type: string + currentSceneCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + status: + type: string + enum: [ACTIVE, ARCHIVED, DELETED] + StartStoryRequest: + type: object + required: [scenarioId, title] + properties: + scenarioId: + type: integer + format: int64 + title: + type: string + UpdateStoryRequest: + type: object + properties: + title: + type: string + currentSceneDescription: + type: string + currentSceneCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + StoryStep: + type: object + required: [id, stepNumber, content, addedToScene] + properties: + id: + type: integer + format: int64 + stepNumber: + type: integer + content: + type: string + userDirection: + type: string + addedToScene: + type: boolean + UpdateStepRequest: + type: object + properties: + content: + type: string + GenerateStepRequest: + type: object + required: [direction] + properties: + direction: + type: string + currentSceneDescription: + type: string + currentCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + GenerateStepResponse: + type: object + required: [step, compacted] + properties: + step: + $ref: '#/components/schemas/StoryStep' + compacted: + type: boolean + description: True if context compaction was performed before generation. + updatedScene: + type: string + description: New scene description after compaction, if compacted is true. + updatedCharacters: + type: array + items: + $ref: '#/components/schemas/CharacterDto' + description: Updated characters after compaction, if compacted is true. diff --git a/src/main/resources/privateKey.pem b/src/main/resources/privateKey.pem new file mode 100644 index 0000000..a3e04f0 --- /dev/null +++ b/src/main/resources/privateKey.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6YIKoRr2JZ6wd +xMD1t8l0xzjDftktJDU9Z52jicWjeAQMsZEzzeZ8tBhM+XVhsHKI00nPuU9VjQCM +IpNbg7o9z1gxALmgvv0erj/QAST0T3/5+Miyy3+FKm/srqN4C5r0Xy5Dx+3TNnvR +VXwePfp/QHKsi2FfirAOx1s9FBSPckhWN0QNMkeghP9Oy/ScXY1zzVneesBlKFB5 +hKS7vhxqzucP3df6Bp3y7xw8Ozi94uS8A5Mmj9O1RbAykhmE5GGUFiCiPXECfM43 +O+0Ilygm6mlve5v/Xno1kEY+4h6B25ojmFs3w1uGQh9xZteZr+TmFvaM2031tZVr +OQ3j8v7/AgMBAAECggEAAbByPrg1a5Nm+fYYkaGSz94mY484xLH5UY8o3JyZD/w8 +fSj22fNZzXOMbNYt/C6vOxI/LwTM9UeL47lEgKXXAExqzjbld9GDVc3agjYgcZ5u +2IMqvoQdqcVSsmB61tG0G9bpAnBDdZCe2qzdrSB+rryUzX+N6GHHarrf1tPhw9MP +YM36Per0/q6D10RS11X4Zubt/DbZ44+pNQdTWzxtQ3zMv/gUegSEVm3kOo+1GK/B +l7WdYshTS81zFbt3hza22tkbqJ6Qdhvk2NLNZSFNBJtBqcrgp5BYV4BUqc+qk/mu ++eWc6yy99nFDcFiut/52RBijLXmUb23P8VXV5aFlUQKBgQDptsEsNVTUZp1dMKgx ++8cwoYrHSkVxus0Z8E+eO+UNT5dzv2dT8yPM+Widt9A2rXAbwGdRY+JGPX4oawRx +j/MSX7suHLRY1LcBjodnKcvJa1uJ14RmSBQnR9Leb55JOrB/f9HlyXsRg7r/0BGT +TJZzXC5jZS0po9zGhZw3M+ZW9QKBgQDMJjQiO1W67IpRMCt4pWjKxnsUPacKZLtr +rgqfr1lGTugMaRoYPZ9uTeXjK6oT/teDZRw3Puxw9+OXpbMJ7RSesVq6nLZP6mMf +9ZHzXhBDMKpW8/YnvAf+hgQDww45a0H3ewJxpLtM8nv4JgWCEfpNGJpzNt5zL0MZ +ZGpQdAR9owKBgHM/MGCZZ9xZQY4bmUUUj/PWJPYDFN1xLQR2cPxpMpjuv5NPNie6 +hNPlxQXJB35+5gJ5TTlgVMsoNZa+tvE897L+y/GALBqFwjydSP3BKYGIVBpT0TES +qAV6sGwJhHc93pzwrdNvGCXZ3JOayZ/mK2Z1dVaEJNIcwJiQeCjsidAlAoGBAKIa +R9VSth1KS/5xuGMBPPeeqQaGeggXunajaQ4pR0M7zqhkIHNVIy6MLlm3R0K/XdOY +ytHXZhnBzHeS/FqKZZApFfkODPniDLnI3g6YB2PC1c9bwn8EoHhrY+60yKuxTl07 +0NH6UzujY+rEDiWdLtpfCe0oiXR+99HG2WBu703vAoGBAMRS7OTt4eao+hEHiEB1 +I/nykaM5IAnX5+S3PEB4l/wpAhBm5NV3wp3GnLaUtxXgpo0Fn+Eq6hwpUl8E5Ema +v1KwJZ8zqq+O8qzKgEvC9HiG7XJ6Y50kXaGxK2BUC0BBh6cZQghGm35RycZgptKq +nmdKX8jcOu8cfZgjs1DwruEi +-----END PRIVATE KEY----- diff --git a/src/main/resources/publicKey.pem b/src/main/resources/publicKey.pem new file mode 100644 index 0000000..0dfeee6 --- /dev/null +++ b/src/main/resources/publicKey.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAumCCqEa9iWesHcTA9bfJ +dMc4w37ZLSQ1PWedo4nFo3gEDLGRM83mfLQYTPl1YbByiNNJz7lPVY0AjCKTW4O6 +Pc9YMQC5oL79Hq4/0AEk9E9/+fjIsst/hSpv7K6jeAua9F8uQ8ft0zZ70VV8Hj36 +f0ByrIthX4qwDsdbPRQUj3JIVjdEDTJHoIT/Tsv0nF2Nc81Z3nrAZShQeYSku74c +as7nD93X+gad8u8cPDs4veLkvAOTJo/TtUWwMpIZhORhlBYgoj1xAnzONzvtCJco +Juppb3ub/156NZBGPuIegduaI5hbN8NbhkIfcWbXma/k5hb2jNtN9bWVazkN4/L+ +/wIDAQAB +-----END PUBLIC KEY----- diff --git a/src/main/web/index.html b/src/main/web/index.html new file mode 100644 index 0000000..7f24c9c --- /dev/null +++ b/src/main/web/index.html @@ -0,0 +1,12 @@ + + + + + + StoryTeller + + +
+ + + diff --git a/src/main/web/package-lock.json b/src/main/web/package-lock.json new file mode 100644 index 0000000..1cd8ea6 --- /dev/null +++ b/src/main/web/package-lock.json @@ -0,0 +1,1906 @@ +{ + "name": "storyteller-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "storyteller-web", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/react": "^18.3.21", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/src/main/web/package.json b/src/main/web/package.json new file mode 100644 index 0000000..f6180fc --- /dev/null +++ b/src/main/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "storyteller-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@types/node": "^22.15.21", + "@types/react": "^18.3.21", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "^5.8.3", + "vite": "^6.3.5" + } +} diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx new file mode 100644 index 0000000..f6894fc --- /dev/null +++ b/src/main/web/src/App.tsx @@ -0,0 +1,51 @@ +import { Link, Route, Routes } from "react-router-dom"; +import { useAuth } from "./context/AuthContext"; +import { ProtectedRoute } from "./components/ProtectedRoute"; +import { LoginPage } from "./pages/LoginPage"; +import { HomePage } from "./pages/HomePage"; +import { ScenariosPage } from "./pages/ScenariosPage"; +import { StoriesPage } from "./pages/StoriesPage"; +import { StoryWorkspacePage } from "./pages/StoryWorkspacePage"; +import { AdminUsersPage } from "./pages/AdminUsersPage"; + +/** + * Root application shell with navigation and route definitions. + */ +export default function App() { + const { auth, logout } = useAuth(); + + return ( +
+
+ StoryTeller + {auth && ( + + )} +
+ {auth ? ( + <> + {auth.username} + + + ) : ( + Sign In + )} +
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} diff --git a/src/main/web/src/api/client.ts b/src/main/web/src/api/client.ts new file mode 100644 index 0000000..20bf90d --- /dev/null +++ b/src/main/web/src/api/client.ts @@ -0,0 +1,39 @@ +import { + AuthApi, + Configuration, + ScenariosApi, + StoriesApi, + UsersApi, +} from "./generated/src"; + +const STORAGE_KEY = "storyteller_auth"; + +/** + * Reads the JWT from localStorage and returns auth headers if available. + */ +function getAuthHeaders(): Record { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const auth = JSON.parse(stored); + if (auth?.token) { + return { Authorization: `Bearer ${auth.token}` }; + } + } catch { /* ignore malformed storage */ } + } + return {}; +} + +/** + * Returns fresh API client instances with current auth token. + * Call this inside components/effects to ensure the latest token is used. + */ +export function getApiClient() { + const config = new Configuration({ basePath: "/api", headers: getAuthHeaders() }); + return { + auth: new AuthApi(new Configuration({ basePath: "/api" })), + users: new UsersApi(config), + scenarios: new ScenariosApi(config), + stories: new StoriesApi(config), + }; +} diff --git a/src/main/web/src/components/ProtectedRoute.tsx b/src/main/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..c46ca56 --- /dev/null +++ b/src/main/web/src/components/ProtectedRoute.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import type { ReactNode } from "react"; + +interface Props { + children: ReactNode; + adminOnly?: boolean; +} + +/** + * Route guard that redirects unauthenticated users to the login page. + * If adminOnly is true, also redirects non-admin users. + */ +export function ProtectedRoute({ children, adminOnly = false }: Props) { + const { auth } = useAuth(); + if (!auth) return ; + if (adminOnly && !auth.isAdmin) return ; + return <>{children}; +} diff --git a/src/main/web/src/context/AuthContext.tsx b/src/main/web/src/context/AuthContext.tsx new file mode 100644 index 0000000..6de8041 --- /dev/null +++ b/src/main/web/src/context/AuthContext.tsx @@ -0,0 +1,57 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; + +interface AuthState { + token: string; + userId: number; + username: string; + isAdmin: boolean; +} + +interface AuthContextValue { + auth: AuthState | null; + login: (state: AuthState) => void; + logout: () => void; +} + +const STORAGE_KEY = "storyteller_auth"; + +const AuthContext = createContext({ + auth: null, + login: () => {}, + logout: () => {}, +}); + +/** + * Provides authentication state to the component tree. + * Persists auth state in localStorage across page reloads. + */ +export function AuthProvider({ children }: { children: ReactNode }) { + const [auth, setAuth] = useState(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { return JSON.parse(stored); } catch { return null; } + } + return null; + }); + + useEffect(() => { + if (auth) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); + } else { + localStorage.removeItem(STORAGE_KEY); + } + }, [auth]); + + const login = useCallback((state: AuthState) => setAuth(state), []); + const logout = useCallback(() => setAuth(null), []); + + const value = useMemo(() => ({ auth, login, logout }), [auth, login, logout]); + + return {children}; +} + +/** Hook to access auth state and actions. */ +export function useAuth() { + return useContext(AuthContext); +} diff --git a/src/main/web/src/main.tsx b/src/main/web/src/main.tsx new file mode 100644 index 0000000..4ff6ebf --- /dev/null +++ b/src/main/web/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/src/main/web/src/pages/AdminUsersPage.tsx b/src/main/web/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..297205b --- /dev/null +++ b/src/main/web/src/pages/AdminUsersPage.tsx @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useState } from "react"; +import type { User, CreateUserRequest } from "../api/generated/src"; +import { getApiClient } from "../api/client"; + +/** + * Admin user management page: list, create, edit, and delete users. + */ +export function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + + const load = useCallback(async () => { + try { + const api = getApiClient(); + setUsers(await api.users.listUsers()); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load users."); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleCreate = async (data: CreateUserRequest) => { + const api = getApiClient(); + await api.users.createUser({ createUserRequest: data }); + setShowCreate(false); + load(); + }; + + const handleToggleActive = async (user: User) => { + const api = getApiClient(); + await api.users.updateUser({ userId: user.id, updateUserRequest: { active: !user.active } }); + load(); + }; + + const handleToggleAdmin = async (user: User) => { + const api = getApiClient(); + await api.users.updateUser({ userId: user.id, updateUserRequest: { isAdmin: !user.isAdmin } }); + load(); + }; + + const handleDelete = async (userId: number) => { + if (!confirm("Deactivate this user?")) return; + const api = getApiClient(); + await api.users.deleteUser({ userId }); + load(); + }; + + return ( +
+
+

User Management

+ +
+ {error &&

{error}

} + + {showCreate && } + + + + + + + + + + + + {users.map((u) => ( + + + + + + + ))} + +
UsernameAdminActiveActions
{u.username} + + + + + +
+
+ ); +} + +function CreateUserForm({ onSubmit }: { onSubmit: (data: CreateUserRequest) => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isAdmin, setIsAdmin] = useState(false); + + return ( +
{ + e.preventDefault(); + onSubmit({ username, password, isAdmin }); + }} + > + + + + +
+ ); +} diff --git a/src/main/web/src/pages/HomePage.tsx b/src/main/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..fa02fa0 --- /dev/null +++ b/src/main/web/src/pages/HomePage.tsx @@ -0,0 +1,23 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; + +/** + * Landing page showing quick-start actions based on the user's role. + */ +export function HomePage() { + const { auth } = useAuth(); + + return ( +
+

Welcome, {auth?.username ?? "Storyteller"}!

+

+ StoryTeller helps you collaborate with AI to build stories step by step. +

+
+ My Scenarios + My Stories + {auth?.isAdmin && Manage Users} +
+
+ ); +} diff --git a/src/main/web/src/pages/LoginPage.tsx b/src/main/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..b798e8f --- /dev/null +++ b/src/main/web/src/pages/LoginPage.tsx @@ -0,0 +1,50 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../context/AuthContext"; +import { getApiClient } from "../api/client"; + +/** + * Login page with username/password form. + */ +export function LoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const api = getApiClient(); + const resp = await api.auth.login({ loginRequest: { username, password } }); + login({ token: resp.token, userId: resp.userId, username: resp.username, isAdmin: resp.isAdmin }); + navigate("/"); + } catch { + setError("Invalid username or password."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Sign In

+ {error &&

{error}

} + + + +
+
+ ); +} diff --git a/src/main/web/src/pages/ScenariosPage.tsx b/src/main/web/src/pages/ScenariosPage.tsx new file mode 100644 index 0000000..c82f11c --- /dev/null +++ b/src/main/web/src/pages/ScenariosPage.tsx @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import type { Scenario, CharacterDto } from "../api/generated/src"; +import { getApiClient } from "../api/client"; + +/** + * Scenario list page with create, edit, and delete actions. + */ +export function ScenariosPage() { + const [scenarios, setScenarios] = useState([]); + const [error, setError] = useState(null); + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + try { + const api = getApiClient(); + setScenarios(await api.scenarios.listScenarios()); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load scenarios."); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const handleDelete = async (id: number) => { + if (!confirm("Delete this scenario?")) return; + const api = getApiClient(); + await api.scenarios.deleteScenario({ scenarioId: id }); + load(); + }; + + return ( +
+
+

Scenarios

+ +
+ {error &&

{error}

} + + {(creating || editing) && ( + { setCreating(false); setEditing(null); load(); }} + onCancel={() => { setCreating(false); setEditing(null); }} + /> + )} + +
+ {scenarios.map((s) => ( +
+

{s.title}

+ {s.description &&

{s.description}

} +

Scene: {truncate(s.startingSceneDescription, 150)}

+

{s.startingCharacters.length} character(s)

+
+ + + Start Story +
+
+ ))} + {scenarios.length === 0 && !creating &&

No scenarios yet. Create one to get started.

} +
+
+ ); +} + +function truncate(text: string, max: number) { + return text.length > max ? text.substring(0, max) + "..." : text; +} + +interface EditorProps { + scenario: Scenario | null; + onSave: () => void; + onCancel: () => void; +} + +function ScenarioEditor({ scenario, onSave, onCancel }: EditorProps) { + const [title, setTitle] = useState(scenario?.title ?? ""); + const [description, setDescription] = useState(scenario?.description ?? ""); + const [sceneDesc, setSceneDesc] = useState(scenario?.startingSceneDescription ?? ""); + const [chars, setChars] = useState(scenario?.startingCharacters ?? []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const api = getApiClient(); + if (scenario) { + await api.scenarios.updateScenario({ + scenarioId: scenario.id, + updateScenarioRequest: { title, description, startingSceneDescription: sceneDesc, startingCharacters: chars }, + }); + } else { + await api.scenarios.createScenario({ + createScenarioRequest: { title, description, startingSceneDescription: sceneDesc, startingCharacters: chars }, + }); + } + onSave(); + }; + + const addChar = () => setChars([...chars, { name: "", role: "", description: "" }]); + const removeChar = (i: number) => setChars(chars.filter((_, idx) => idx !== i)); + const updateChar = (i: number, field: keyof CharacterDto, val: string) => { + const next = [...chars]; + next[i] = { ...next[i], [field]: val }; + setChars(next); + }; + + return ( +
+

{scenario ? "Edit Scenario" : "New Scenario"}

+ +