diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml
index 615c8cb354..6c470c19b4 100644
--- a/.github/workflows/cypress-tests.yml
+++ b/.github/workflows/cypress-tests.yml
@@ -34,7 +34,7 @@ jobs:
mkcert -cert-file localhost.pem -key-file localhost-key.pem \
localhost 127.0.0.1 ::1 oidc-simulator host.docker.internal
- # Copy mkcert root CA to the certs directory for Docker containers
+ # Copy mkcert root CA for other containers
cp "$(mkcert -CAROOT)/rootCA.pem" ./rootCA.pem
# List generated files for debugging
@@ -89,11 +89,14 @@ jobs:
if: always()
run: |
# Show logs from critical services for debugging
+ echo "=== NGINX Proxy logs ==="
+ docker compose -f docker-compose.test.yml logs nginx-proxy || true
+
echo "=== OIDC Simulator logs ==="
- docker compose -f docker-compose.test.yml logs oidc-simulator | tail -100
+ docker compose -f docker-compose.test.yml logs oidc-simulator || true
echo "=== Server logs ==="
- docker compose -f docker-compose.test.yml logs server | tail -100
+ docker compose -f docker-compose.test.yml logs server || true
- name: Run auth setup test
run: |
diff --git a/.github/workflows/jest-server-test.yml b/.github/workflows/jest-server-test.yml
index 42b0df2338..85d52561c3 100644
--- a/.github/workflows/jest-server-test.yml
+++ b/.github/workflows/jest-server-test.yml
@@ -65,13 +65,13 @@ jobs:
run: |
# Build all services except server (tests run on host)
docker compose -f docker-compose.test.yml --env-file test.env build \
- math postgres file-server maildev oidc-simulator
+ math postgres file-server maildev oidc-simulator dynamodb
- name: Start services
run: |
# Start only required services in detached mode (exclude server)
docker compose -f docker-compose.test.yml --env-file test.env up -d \
- math postgres file-server maildev oidc-simulator
+ math postgres file-server maildev oidc-simulator dynamodb
# Wait for services to be ready
echo "Waiting for services to start..."
@@ -88,6 +88,11 @@ jobs:
echo "Checking oidc-simulator..."
curl -k -f https://localhost:3000/.well-known/jwks.json || true
+
+ echo "Checking DynamoDB..."
+ curl -f http://localhost:8000 || true
+ # Alternative check using AWS CLI if available
+ aws dynamodb list-tables --endpoint-url http://localhost:8000 --region us-east-1 2>/dev/null || true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -114,6 +119,7 @@ jobs:
export DATABASE_URL=postgres://postgres:PdwPNS2mDN73Vfbc@localhost:5432/polis-test
export MAILDEV_HOST=localhost
export STATIC_FILES_HOST=localhost
+ export DYNAMODB_ENDPOINT=http://localhost:8000
# Run the tests on the host machine
npm test -- --ci --coverage --maxWorkers=2
@@ -138,6 +144,9 @@ jobs:
echo "=== Math logs ==="
docker compose -f docker-compose.test.yml logs math | tail -50
+
+ echo "=== DynamoDB logs ==="
+ docker compose -f docker-compose.test.yml logs dynamodb | tail -100
- name: Clean up
if: always()
diff --git a/Makefile b/Makefile
index 9141f31f57..dcb22a0e7b 100644
--- a/Makefile
+++ b/Makefile
@@ -102,8 +102,8 @@ start-FULL-REBUILD: echo_vars stop rm-ALL ## Remove and restart all Docker conta
docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} build --no-cache
docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG}
-rebuild-web: echo_vars ## Rebuild and restart just the file-server container and its static assets
- docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG} --build --force-recreate file-server
+rebuild-web: echo_vars ## Rebuild and restart just the file-server container and its static assets, and client-participation-alpha
+ docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} up ${DETACH_ARG} --build --force-recreate file-server client-participation-alpha
build-web-assets: ## Build and extract static web assets for cloud deployment to `build` dir
docker compose ${COMPOSE_FILE_ARGS} --env-file ${ENV_FILE} create --build --force-recreate file-server
diff --git a/client-admin/src/components/conversation-admin/seed-comment.js b/client-admin/src/components/conversation-admin/seed-comment.js
index eb37ba2a83..8d4a35a194 100644
--- a/client-admin/src/components/conversation-admin/seed-comment.js
+++ b/client-admin/src/components/conversation-admin/seed-comment.js
@@ -117,7 +117,7 @@ const ModerateCommentsSeed = ({ params }) => {
Upload a CSV of seed comments
-
+
)
diff --git a/client-participation-alpha/.astro/data-store.json b/client-participation-alpha/.astro/data-store.json
index e72bff0062..f68e761761 100644
--- a/client-participation-alpha/.astro/data-store.json
+++ b/client-participation-alpha/.astro/data-store.json
@@ -1 +1 @@
-[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/colinmegill/polis/client-participation-alpha/node_modules/.astro/sessions\"}}}"]
\ No newline at end of file
+[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.11.0","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"server\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":\"0.0.0.0\",\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\",\"entrypoint\":\"astro/assets/endpoint/node\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false},\"legacy\":{\"collections\":false},\"session\":{\"driver\":\"fs-lite\",\"options\":{\"base\":\"/Users/bennie/code/src/github.com/compdemocracy/polis/client-participation-alpha/node_modules/.astro/sessions\"}}}"]
\ No newline at end of file
diff --git a/client-participation-alpha/.astro/settings.json b/client-participation-alpha/.astro/settings.json
index 22ce6f2977..a833462e41 100644
--- a/client-participation-alpha/.astro/settings.json
+++ b/client-participation-alpha/.astro/settings.json
@@ -1,5 +1,5 @@
{
"_variables": {
- "lastUpdateCheck": 1753121227723
+ "lastUpdateCheck": 1754517865792
}
}
\ No newline at end of file
diff --git a/client-participation-alpha/.astro/types.d.ts b/client-participation-alpha/.astro/types.d.ts
index 03d7cc43f1..f964fe0cff 100644
--- a/client-participation-alpha/.astro/types.d.ts
+++ b/client-participation-alpha/.astro/types.d.ts
@@ -1,2 +1 @@
///
-///
\ No newline at end of file
diff --git a/client-participation-alpha/README-AUTH.md b/client-participation-alpha/README-AUTH.md
new file mode 100644
index 0000000000..3ff8f423ae
--- /dev/null
+++ b/client-participation-alpha/README-AUTH.md
@@ -0,0 +1,191 @@
+# Authentication and JWT Handling in Client-Participation-Alpha
+
+## Overview
+
+The alpha client now has centralized JWT and authentication handling that mirrors the legacy client's approach but with modern patterns. All JWT tokens are automatically extracted and stored from API responses, and authentication headers are automatically added to requests.
+
+## Key Components
+
+### 1. `auth.ts` - Authentication Management
+
+- `setJwtToken(token)` - Stores JWT tokens per conversation (extracts conversation_id from token)
+- `getConversationToken(conversation_id)` - Retrieves stored JWT for a specific conversation
+- `handleJwtFromResponse(response)` - Automatically extracts and stores JWT from API responses
+- `getConversationIdFromUrl()` - Extracts conversation_id from URL path (e.g., /alpha/2demo → "2demo")
+
+### 2. `net.js` - Network Layer with Auto-Auth
+
+- Automatically adds authentication headers to all requests
+- Automatically extracts and stores JWT tokens from all responses
+- Handles both OIDC tokens and conversation-specific JWTs
+- Provides consistent error handling with auth error details
+
+## Usage Examples
+
+### Simple API Call (JWT handled automatically)
+
+```javascript
+import PolisNet from '../lib/net';
+
+// Make an API call - JWT will be automatically added to headers if available
+// and automatically extracted/stored from response
+const result = await PolisNet.polisPost('/api/v3/votes', {
+ conversation_id: 'abc123',
+ tid: 42,
+ vote: 1
+});
+// No need to manually handle result.auth.token - it's done automatically!
+```
+
+### Component Example
+
+```javascript
+import PolisNet from '../lib/net';
+import { getConversationToken } from '../lib/auth';
+
+export function MyComponent({ conversation_id }) {
+ const submitData = async (data) => {
+ // Get current participant info if needed
+ const token = getConversationToken(conversation_id);
+ const pid = token?.pid;
+
+ try {
+ // Make API call - auth headers added automatically
+ const response = await PolisNet.polisPost('/api/v3/comments', {
+ conversation_id,
+ pid,
+ txt: data.text
+ });
+
+ // JWT token from response.auth.token is automatically stored
+ // No manual handling needed!
+
+ return response;
+ } catch (error) {
+ // Error includes responseText for server error messages
+ console.error('API Error:', error.responseText || error.message);
+ throw error;
+ }
+ };
+}
+```
+
+## URL Structure and Conversation ID
+
+The conversation_id is extracted from the URL path, not query parameters:
+
+- `https://pol.is/alpha/2demo` → conversation_id: "2demo"
+- `http://localhost:4321/3xyz` → conversation_id: "3xyz"
+- `https://edge.pol.is/alpha/4abc` → conversation_id: "4abc"
+
+The pattern matches:
+
+- `/alpha/:conversation_id` (production)
+- `/:conversation_id` (development)
+
+Conversation IDs always start with a digit followed by alphanumeric characters.
+
+## Authentication Flow
+
+1. **Initial Page Load (SSR)**
+ - Server fetches initial data with participationInit
+ - JWT token included in response
+ - Client-side script stores token on hydration
+
+2. **Subsequent API Calls**
+ - `net.js` checks for OIDC token first
+ - Falls back to conversation-specific JWT (extracted from URL path)
+ - Automatically adds to Authorization header
+ - Server responds with updated JWT if needed
+ - `net.js` automatically stores new JWT
+
+3. **Token Priority**
+ - OIDC tokens (if available) take precedence
+ - Conversation-specific JWTs used as fallback (based on current URL)
+ - No token sent if neither available (anonymous access)
+
+## Migration from Manual JWT Handling
+
+### Before (Manual JWT Handling)
+
+```javascript
+// DON'T DO THIS ANYMORE
+const response = await fetch('/api/v3/votes', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data)
+});
+
+const result = await response.json();
+
+// Manual JWT extraction and storage
+if (result?.auth?.token) {
+ const token = result.auth.token;
+ const parts = token.split('.');
+ if (parts.length === 3) {
+ const payload = JSON.parse(atob(parts[1]));
+ if (payload.conversation_id) {
+ localStorage.setItem('participant_token_' + payload.conversation_id, token);
+ }
+ }
+}
+```
+
+### After (Centralized Handling)
+
+```javascript
+// DO THIS INSTEAD
+import PolisNet from '../lib/net';
+
+const result = await PolisNet.polisPost('/api/v3/votes', data);
+// JWT handling is automatic - no manual code needed!
+```
+
+## Benefits
+
+1. **DRY Principle** - JWT handling logic in one place
+2. **Consistency** - All API calls handle auth the same way
+3. **Error Handling** - Centralized auth error handling
+4. **Maintainability** - Easy to update auth logic globally
+5. **Type Safety** - TypeScript interfaces for auth objects
+6. **Automatic Token Refresh** - Server can send new tokens anytime
+
+## Comparison with Legacy Client
+
+| Feature | Legacy Client | Alpha Client |
+|---------|--------------|--------------|
+| JWT Storage | `polisStorage.js` | `auth.ts` |
+| Auto JWT Extraction | ✅ `net.js`, `main.js` | ✅ `net.js` |
+| Auto Auth Headers | ✅ `backbonePolis.js` | ✅ `net.js` |
+| OIDC Support | ❌ | ✅ |
+| TypeScript | ❌ | ✅ |
+| Centralized | Partial (3 files) | ✅ (2 files) |
+
+## Testing
+
+When testing components that use the network layer:
+
+```javascript
+// Mock the net module
+jest.mock('../lib/net', () => ({
+ polisPost: jest.fn(),
+ polisGet: jest.fn(),
+ polisPut: jest.fn()
+}));
+
+// In tests
+import PolisNet from '../lib/net';
+
+PolisNet.polisPost.mockResolvedValue({
+ success: true,
+ auth: { token: 'mock-jwt-token' }
+});
+```
+
+## Troubleshooting
+
+1. **No auth header sent**: Check if token exists for conversation
+2. **401 errors**: Token may be expired or invalid
+3. **403 errors**: User lacks permission for action
+4. **Token not stored**: Check browser storage settings
+5. **OIDC vs JWT conflicts**: OIDC takes precedence when available
diff --git a/client-participation-alpha/astro.config.mjs b/client-participation-alpha/astro.config.mjs
index c2b4e9aa85..2cff71ee3e 100644
--- a/client-participation-alpha/astro.config.mjs
+++ b/client-participation-alpha/astro.config.mjs
@@ -14,5 +14,10 @@ export default defineConfig({
server: {
host: '0.0.0.0'
},
- integrations: [react()]
+ integrations: [
+ react({
+ experimentalDisableStreaming: true,
+ experimentalReactChildren: true
+ })
+ ]
});
\ No newline at end of file
diff --git a/client-participation-alpha/example.env b/client-participation-alpha/example.env
new file mode 100644
index 0000000000..342bfebed9
--- /dev/null
+++ b/client-participation-alpha/example.env
@@ -0,0 +1,4 @@
+INTERNAL_SERVICE_URL=http://localhost/api/v3
+PUBLIC_SERVICE_URL=http://localhost/api/v3
+PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX=@@user@@
+PUBLIC_OIDC_CACHE_KEY_PREFIX=oidc.user
diff --git a/client-participation-alpha/package-lock.json b/client-participation-alpha/package-lock.json
index 9b65bb189b..2f831bc8d5 100644
--- a/client-participation-alpha/package-lock.json
+++ b/client-participation-alpha/package-lock.json
@@ -11,7 +11,7 @@
"@astrojs/react": "^4.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
- "astro": "^5.11.0",
+ "astro": "^5.12.8",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
@@ -40,11 +40,12 @@
"integrity": "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="
},
"node_modules/@astrojs/markdown-remark": {
- "version": "6.3.2",
- "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.2.tgz",
- "integrity": "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q==",
+ "version": "6.3.5",
+ "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-6.3.5.tgz",
+ "integrity": "sha512-MiR92CkE2BcyWf3b86cBBw/1dKiOH0qhLgXH2OXA6cScrrmmks1Rr4Tl0p/lFpvmgQQrP54Pd1uidJfmxGrpWQ==",
+ "license": "MIT",
"dependencies": {
- "@astrojs/internal-helpers": "0.6.1",
+ "@astrojs/internal-helpers": "0.7.1",
"@astrojs/prism": "3.3.0",
"github-slugger": "^2.0.0",
"hast-util-from-html": "^2.0.3",
@@ -59,7 +60,7 @@
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"shiki": "^3.2.1",
- "smol-toml": "^1.3.1",
+ "smol-toml": "^1.3.4",
"unified": "^11.0.5",
"unist-util-remove-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
@@ -67,6 +68,12 @@
"vfile": "^6.0.3"
}
},
+ "node_modules/@astrojs/markdown-remark/node_modules/@astrojs/internal-helpers": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz",
+ "integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==",
+ "license": "MIT"
+ },
"node_modules/@astrojs/node": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-9.3.0.tgz",
@@ -85,6 +92,7 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-3.3.0.tgz",
"integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==",
+ "license": "MIT",
"dependencies": {
"prismjs": "^1.30.0"
},
@@ -1478,55 +1486,61 @@
]
},
"node_modules/@shikijs/core": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.7.0.tgz",
- "integrity": "sha512-yilc0S9HvTPyahHpcum8eonYrQtmGTU0lbtwxhA6jHv4Bm1cAdlPFRCJX4AHebkCm75aKTjjRAW+DezqD1b/cg==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.9.2.tgz",
+ "integrity": "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA==",
+ "license": "MIT",
"dependencies": {
- "@shikijs/types": "3.7.0",
+ "@shikijs/types": "3.9.2",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4",
"hast-util-to-html": "^9.0.5"
}
},
"node_modules/@shikijs/engine-javascript": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.7.0.tgz",
- "integrity": "sha512-0t17s03Cbv+ZcUvv+y33GtX75WBLQELgNdVghnsdhTgU3hVcWcMsoP6Lb0nDTl95ZJfbP1mVMO0p3byVh3uuzA==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.9.2.tgz",
+ "integrity": "sha512-kUTRVKPsB/28H5Ko6qEsyudBiWEDLst+Sfi+hwr59E0GLHV0h8RfgbQU7fdN5Lt9A8R1ulRiZyTvAizkROjwDA==",
+ "license": "MIT",
"dependencies": {
- "@shikijs/types": "3.7.0",
+ "@shikijs/types": "3.9.2",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.3"
}
},
"node_modules/@shikijs/engine-oniguruma": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.7.0.tgz",
- "integrity": "sha512-5BxcD6LjVWsGu4xyaBC5bu8LdNgPCVBnAkWTtOCs/CZxcB22L8rcoWfv7Hh/3WooVjBZmFtyxhgvkQFedPGnFw==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.9.2.tgz",
+ "integrity": "sha512-Vn/w5oyQ6TUgTVDIC/BrpXwIlfK6V6kGWDVVz2eRkF2v13YoENUvaNwxMsQU/t6oCuZKzqp9vqtEtEzKl9VegA==",
+ "license": "MIT",
"dependencies": {
- "@shikijs/types": "3.7.0",
+ "@shikijs/types": "3.9.2",
"@shikijs/vscode-textmate": "^10.0.2"
}
},
"node_modules/@shikijs/langs": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.7.0.tgz",
- "integrity": "sha512-1zYtdfXLr9xDKLTGy5kb7O0zDQsxXiIsw1iIBcNOO8Yi5/Y1qDbJ+0VsFoqTlzdmneO8Ij35g7QKF8kcLyznCQ==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.9.2.tgz",
+ "integrity": "sha512-X1Q6wRRQXY7HqAuX3I8WjMscjeGjqXCg/Sve7J2GWFORXkSrXud23UECqTBIdCSNKJioFtmUGJQNKtlMMZMn0w==",
+ "license": "MIT",
"dependencies": {
- "@shikijs/types": "3.7.0"
+ "@shikijs/types": "3.9.2"
}
},
"node_modules/@shikijs/themes": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.7.0.tgz",
- "integrity": "sha512-VJx8497iZPy5zLiiCTSIaOChIcKQwR0FebwE9S3rcN0+J/GTWwQ1v/bqhTbpbY3zybPKeO8wdammqkpXc4NVjQ==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.9.2.tgz",
+ "integrity": "sha512-6z5lBPBMRfLyyEsgf6uJDHPa6NAGVzFJqH4EAZ+03+7sedYir2yJBRu2uPZOKmj43GyhVHWHvyduLDAwJQfDjA==",
+ "license": "MIT",
"dependencies": {
- "@shikijs/types": "3.7.0"
+ "@shikijs/types": "3.9.2"
}
},
"node_modules/@shikijs/types": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.7.0.tgz",
- "integrity": "sha512-MGaLeaRlSWpnP0XSAum3kP3a8vtcTsITqoEPYdt3lQG3YCdQH4DnEhodkYcNMcU0uW0RffhoD1O3e0vG5eSBBg==",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.9.2.tgz",
+ "integrity": "sha512-/M5L0Uc2ljyn2jKvj4Yiah7ow/W+DJSglVafvWAJ/b8AZDeeRAdMu3c2riDzB7N42VD+jSnWxeP9AKtd4TfYVw==",
+ "license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
@@ -1535,7 +1549,8 @@
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
- "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
@@ -1590,6 +1605,7 @@
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
+ "license": "MIT",
"dependencies": {
"@types/ms": "*"
}
@@ -1626,12 +1642,14 @@
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
- "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
+ "license": "MIT"
},
"node_modules/@types/nlcst": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz",
"integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==",
+ "license": "MIT",
"dependencies": {
"@types/unist": "*"
}
@@ -1796,7 +1814,8 @@
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.2",
@@ -1810,19 +1829,21 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz",
"integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/astro": {
- "version": "5.11.0",
- "resolved": "https://registry.npmjs.org/astro/-/astro-5.11.0.tgz",
- "integrity": "sha512-MEICntERthUxJPSSDsDiZuwiCMrsaYy3fnDhp4c6ScUfldCB8RBnB/myYdpTFXpwYBy6SgVsHQ1H4MuuA7ro/Q==",
+ "version": "5.12.8",
+ "resolved": "https://registry.npmjs.org/astro/-/astro-5.12.8.tgz",
+ "integrity": "sha512-KkJ7FR+c2SyZYlpakm48XBiuQcRsrVtdjG5LN5an0givI/tLik+ePJ4/g3qrAVhYMjJOxBA2YgFQxANPiWB+Mw==",
+ "license": "MIT",
"dependencies": {
"@astrojs/compiler": "^2.12.2",
- "@astrojs/internal-helpers": "0.6.1",
- "@astrojs/markdown-remark": "6.3.2",
+ "@astrojs/internal-helpers": "0.7.1",
+ "@astrojs/markdown-remark": "6.3.5",
"@astrojs/telemetry": "3.3.0",
"@capsizecss/unpack": "^2.4.0",
"@oslojs/encoding": "^1.1.0",
@@ -1865,6 +1886,7 @@
"rehype": "^13.0.2",
"semver": "^7.7.1",
"shiki": "^3.2.1",
+ "smol-toml": "^1.3.4",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.12",
"tsconfck": "^3.1.5",
@@ -1878,7 +1900,7 @@
"xxhash-wasm": "^1.1.0",
"yargs-parser": "^21.1.1",
"yocto-spinner": "^0.2.1",
- "zod": "^3.24.2",
+ "zod": "^3.24.4",
"zod-to-json-schema": "^3.24.5",
"zod-to-ts": "^1.2.0"
},
@@ -1898,6 +1920,12 @@
"sharp": "^0.33.3"
}
},
+ "node_modules/astro/node_modules/@astrojs/internal-helpers": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.7.1.tgz",
+ "integrity": "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ==",
+ "license": "MIT"
+ },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -2074,6 +2102,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -2291,6 +2320,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
"integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==",
+ "license": "MIT",
"dependencies": {
"character-entities": "^2.0.0"
},
@@ -2490,6 +2520,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
+ "license": "MIT",
"engines": {
"node": ">=12"
},
@@ -2620,7 +2651,8 @@
"node_modules/github-slugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz",
- "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="
+ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
+ "license": "ISC"
},
"node_modules/h3": {
"version": "1.15.3",
@@ -2678,6 +2710,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
@@ -2702,6 +2735,7 @@
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
@@ -2748,6 +2782,7 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz",
"integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
@@ -2766,6 +2801,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
"integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -2775,6 +2811,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
@@ -2862,6 +2899,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
"integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -2961,6 +2999,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
@@ -3004,6 +3043,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -3036,6 +3076,7 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
+ "license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -3045,6 +3086,7 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz",
"integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
@@ -3059,6 +3101,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
@@ -3074,6 +3117,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz",
"integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
@@ -3097,6 +3141,7 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
+ "license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
@@ -3115,6 +3160,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
@@ -3131,6 +3177,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
@@ -3147,6 +3194,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
@@ -3161,6 +3209,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
@@ -3177,6 +3226,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
@@ -3192,6 +3242,7 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"unist-util-is": "^6.0.0"
@@ -3225,6 +3276,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"@types/unist": "^3.0.0",
@@ -3245,6 +3297,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0"
},
@@ -3272,6 +3325,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -3306,6 +3360,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"decode-named-character-reference": "^1.0.0",
"devlop": "^1.0.0",
@@ -3329,6 +3384,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
+ "license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
@@ -3348,6 +3404,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
+ "license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
@@ -3363,6 +3420,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
@@ -3382,6 +3440,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
@@ -3399,6 +3458,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
@@ -3415,6 +3475,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
+ "license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
@@ -3427,6 +3488,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
@@ -3453,6 +3515,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
@@ -3473,6 +3536,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-character": "^2.0.0",
@@ -3494,6 +3558,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-types": "^2.0.0"
@@ -3513,6 +3578,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
@@ -3534,6 +3600,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
@@ -3574,6 +3641,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
@@ -3592,6 +3660,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
@@ -3612,6 +3681,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-chunked": "^2.0.0",
"micromark-util-types": "^2.0.0"
@@ -3631,6 +3701,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
@@ -3649,6 +3720,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"decode-named-character-reference": "^1.0.0",
"micromark-util-character": "^2.0.0",
@@ -3684,7 +3756,8 @@
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
- ]
+ ],
+ "license": "MIT"
},
"node_modules/micromark-util-normalize-identifier": {
"version": "2.0.1",
@@ -3700,6 +3773,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0"
}
@@ -3718,6 +3792,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
}
@@ -3756,6 +3831,7 @@
"url": "https://opencollective.com/unified"
}
],
+ "license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
@@ -3856,6 +3932,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz",
"integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0"
},
@@ -3937,12 +4014,14 @@
"node_modules/oniguruma-parser": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
- "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="
+ "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==",
+ "license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.3.tgz",
"integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==",
+ "license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.1",
"regex": "^6.0.1",
@@ -4003,6 +4082,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz",
"integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0",
"@types/unist": "^3.0.0",
@@ -4074,6 +4154,7 @@
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
+ "license": "MIT",
"engines": {
"node": ">=6"
}
@@ -4167,6 +4248,7 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz",
"integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==",
+ "license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
@@ -4175,6 +4257,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
@@ -4182,7 +4265,8 @@
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
- "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
},
"node_modules/rehype": {
"version": "13.0.2",
@@ -4217,6 +4301,7 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0",
@@ -4245,6 +4330,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
@@ -4262,6 +4348,7 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
"integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
@@ -4277,6 +4364,7 @@
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
+ "license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
@@ -4293,6 +4381,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.2.tgz",
"integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==",
+ "license": "MIT",
"dependencies": {
"retext": "^9.0.0",
"retext-smartypants": "^6.0.0",
@@ -4307,6 +4396,7 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
+ "license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
@@ -4326,6 +4416,7 @@
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz",
"integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0",
"retext-latin": "^4.0.0",
@@ -4341,6 +4432,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz",
"integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0",
"parse-latin": "^7.0.0",
@@ -4355,6 +4447,7 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz",
"integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0",
"nlcst-to-string": "^4.0.0",
@@ -4369,6 +4462,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz",
"integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==",
+ "license": "MIT",
"dependencies": {
"@types/nlcst": "^2.0.0",
"nlcst-to-string": "^4.0.0",
@@ -4508,16 +4602,17 @@
}
},
"node_modules/shiki": {
- "version": "3.7.0",
- "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.7.0.tgz",
- "integrity": "sha512-ZcI4UT9n6N2pDuM2n3Jbk0sR4Swzq43nLPgS/4h0E3B/NrFn2HKElrDtceSf8Zx/OWYOo7G1SAtBLypCp+YXqg==",
- "dependencies": {
- "@shikijs/core": "3.7.0",
- "@shikijs/engine-javascript": "3.7.0",
- "@shikijs/engine-oniguruma": "3.7.0",
- "@shikijs/langs": "3.7.0",
- "@shikijs/themes": "3.7.0",
- "@shikijs/types": "3.7.0",
+ "version": "3.9.2",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.9.2.tgz",
+ "integrity": "sha512-t6NKl5e/zGTvw/IyftLcumolgOczhuroqwXngDeMqJ3h3EQiTY/7wmfgPlsmloD8oYfqkEDqxiaH37Pjm1zUhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "3.9.2",
+ "@shikijs/engine-javascript": "3.9.2",
+ "@shikijs/engine-oniguruma": "3.9.2",
+ "@shikijs/langs": "3.9.2",
+ "@shikijs/themes": "3.9.2",
+ "@shikijs/types": "3.9.2",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.4"
}
@@ -4540,6 +4635,7 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.1.tgz",
"integrity": "sha512-CxdwHXyYTONGHThDbq5XdwbFsuY4wlClRGejfE2NtwUtiHYsP1QtNsHb/hnj31jKYSchztJsaA8pSQoVzkfCFg==",
+ "license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
@@ -4791,6 +4887,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
+ "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
@@ -4816,6 +4913,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz",
"integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==",
+ "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"array-iterate": "^2.0.0"
@@ -4841,6 +4939,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz",
"integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==",
+ "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-visit": "^5.0.0"
@@ -4880,6 +4979,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz",
"integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==",
+ "license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
diff --git a/client-participation-alpha/package.json b/client-participation-alpha/package.json
index aadfca2e57..b9bc836ce9 100644
--- a/client-participation-alpha/package.json
+++ b/client-participation-alpha/package.json
@@ -4,6 +4,7 @@
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
+ "start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
@@ -13,7 +14,7 @@
"@astrojs/react": "^4.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
- "astro": "^5.11.0",
+ "astro": "^5.12.8",
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
diff --git a/client-participation-alpha/public/favicon.ico b/client-participation-alpha/public/favicon.ico
new file mode 100644
index 0000000000..a9f9d6196c
Binary files /dev/null and b/client-participation-alpha/public/favicon.ico differ
diff --git a/client-participation-alpha/src/components/EmailSubscribeForm.jsx b/client-participation-alpha/src/components/EmailSubscribeForm.jsx
index 027e060bd6..d81a4bb48f 100644
--- a/client-participation-alpha/src/components/EmailSubscribeForm.jsx
+++ b/client-participation-alpha/src/components/EmailSubscribeForm.jsx
@@ -1,27 +1,12 @@
+import PolisNet from '../lib/net';
import React, { useState } from 'react';
const subscribeAPI = async (email, conversation_id) => {
- const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/notifications`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- type: 1,
- email: email,
- conversation_id: conversation_id,
- }),
- credentials: 'include',
+ return await PolisNet.polisPost('/notifications', {
+ type: 1,
+ email,
+ conversation_id,
});
-
- if (!response.ok) {
- const errorText = await response.text();
- const error = new Error(errorText || 'Subscription failed');
- error.status = response.status;
- throw error;
- }
-
- return await response.json();
};
export default function EmailSubscribeForm({ s, conversation_id }) {
diff --git a/client-participation-alpha/src/components/Survey.jsx b/client-participation-alpha/src/components/Survey.jsx
index 2c40fa9ec0..b7354d5b04 100644
--- a/client-participation-alpha/src/components/Survey.jsx
+++ b/client-participation-alpha/src/components/Survey.jsx
@@ -1,62 +1,30 @@
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { Statement } from './Statement';
import EmailSubscribeForm from './EmailSubscribeForm';
import { getPreferredLanguages } from '../strings/strings';
import { getConversationToken } from '../lib/auth';
+import PolisNet from '../lib/net';
const submitVoteAndGetNextCommentAPI = async (vote, conversation_id, high_priority = false) => {
const decodedToken = getConversationToken(conversation_id);
- const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/votes`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
+
+ try {
+ const resp = await PolisNet.polisPost('/votes', {
agid: 1,
conversation_id,
high_priority,
lang: getPreferredLanguages()[0],
- pid: decodedToken?.pid || "mypid",
+ pid: decodedToken?.pid || -1,
tid: vote.tid,
vote: vote.vote,
- }),
- credentials: 'include',
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- const error = new Error(errorText || 'Vote failed');
- error.status = response.status;
+ });
+
+ return resp;
+ } catch (error) {
+ // The net module already handles JWT extraction and storage
+ // Just re-throw the error for the component to handle
throw error;
}
-
-
- const resp = await response.json();
-
- if (resp?.auth?.token) {
- // Store the token for later use
- try {
- const token = resp.auth.token;
- const parts = token.split('.');
- if (parts.length === 3) {
- const payload = JSON.parse(atob(parts[1]));
- if (payload.conversation_id) {
- const tokenKey = "participant_token_" + payload.conversation_id;
- if (window.localStorage) {
- window.localStorage.setItem(tokenKey, token);
- } else if (window.sessionStorage) {
- window.sessionStorage.setItem(tokenKey, token);
- }
- } else {
- console.warn("[Index] No conversation_id in JWT payload, not storing token.");
- }
- }
- } catch (e) {
- console.error("[Index] Failed to store JWT token:", e);
- }
- }
-
- return resp;
};
@@ -66,6 +34,44 @@ export default function Survey({ initialStatement, s, conversation_id }) {
const [isStatementImportant, setIsStatmentImportant] = useState(false);
const [voteError, setVoteError] = useState(null);
+ // On hydration, fetch a participant-personalized next comment.
+ // This replaces the SSR-provided generic comment if needed.
+ useEffect(() => {
+ let cancelled = false;
+ const loadPersonalizedFirst = async () => {
+ try {
+ const decodedToken = getConversationToken(conversation_id);
+ const pid = decodedToken?.pid ?? -1;
+ const lang = getPreferredLanguages()[0];
+ const resp = await PolisNet.polisGet('/nextComment', {
+ conversation_id,
+ lang
+ });
+
+ if (!cancelled) {
+ if (resp && typeof resp.tid !== 'undefined') {
+ const mapped = { tid: resp.tid, txt: resp.txt, remaining: resp.remaining };
+ if (!statement || mapped.tid !== statement.tid) {
+ setStatement(mapped);
+ }
+ } else {
+ // No personalized next comment available; hide the SSR fallback.
+ setStatement(undefined);
+ }
+ }
+ } catch (e) {
+ // Non-fatal; keep SSR statement
+ console.warn('Personalized first comment fetch failed', e);
+ }
+ };
+ loadPersonalizedFirst();
+ return () => {
+ cancelled = true;
+ };
+ // Run once on mount for this conversation
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [conversation_id]);
+
const handleVote = async (voteType, tid) => {
setIsFetchingNext(true);
setVoteError(null);
@@ -83,14 +89,17 @@ export default function Survey({ initialStatement, s, conversation_id }) {
setIsStatmentImportant(false);
} catch (error) {
- console.error("Vote submission failed:", error.message);
+ console.error("Vote submission failed:", error);
let errorMessage = s.commentSendFailed || "Apologies, your vote failed to send. Please check your connection and try again.";
- if (error.message === "polis_err_conversation_is_closed") {
+ // Check error.responseText first (from net.js), then fall back to error.message
+ const errorText = error.responseText || error.message || '';
+
+ if (errorText.includes("polis_err_conversation_is_closed")) {
errorMessage = s.convIsClosed || "This conversation is closed. No further voting is allowed.";
- } else if (error.message === "polis_err_post_votes_social_needed") {
+ } else if (errorText.includes("polis_err_post_votes_social_needed")) {
errorMessage = "You need to sign in to vote.";
- } else if (error.message === "polis_err_xid_not_whitelisted") {
+ } else if (errorText.includes("polis_err_xid_not_whitelisted")) {
errorMessage = "Sorry, you must be registered to vote. Please sign in or contact the conversation owner.";
}
diff --git a/client-participation-alpha/src/components/SurveyForm.jsx b/client-participation-alpha/src/components/SurveyForm.jsx
index da1c72f9a6..16ef4c8d01 100644
--- a/client-participation-alpha/src/components/SurveyForm.jsx
+++ b/client-participation-alpha/src/components/SurveyForm.jsx
@@ -1,56 +1,25 @@
import React, { useState } from 'react';
import { getConversationToken } from '../lib/auth';
+import PolisNet from '../lib/net';
const submitPerspectiveAPI = async (text, conversation_id) => {
const decodedToken = getConversationToken(conversation_id);
const pid = decodedToken?.pid;
try {
- const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/comments`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- txt: text.replace(/\n/g, " "),
- conversation_id,
- pid,
- vote: -1,
- }),
- credentials: 'include',
+ const resp = await PolisNet.polisPost('/comments', {
+ txt: text.replace(/\n/g, " "),
+ conversation_id,
+ pid,
+ vote: -1,
});
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error(`Comment submission failed with status ${response.status}:`, errorText);
- }
-
- const resp = await response.json();
-
- if (resp?.auth?.token) {
- // Store the token for later use
- try {
- const token = resp.auth.token;
- const parts = token.split('.');
- if (parts.length === 3) {
- const payload = JSON.parse(atob(parts[1]));
- if (payload.conversation_id) {
- const tokenKey = "participant_token_" + payload.conversation_id;
- if (window.localStorage) {
- window.localStorage.setItem(tokenKey, token);
- } else if (window.sessionStorage) {
- window.sessionStorage.setItem(tokenKey, token);
- }
- } else {
- console.warn("[Index] No conversation_id in JWT payload, not storing token.");
- }
- }
- } catch (e) {
- console.error("[Index] Failed to store JWT token:", e);
- }
- }
+
+ // The net module automatically handles JWT extraction and storage
+ return resp;
} catch (error) {
- console.error("Network error during comment submission:", error);
+ console.error("Comment submission failed:", error);
+ // Re-throw for caller to handle if needed
+ throw error;
}
};
diff --git a/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx b/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx
index 9668768488..70c32d259a 100644
--- a/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx
+++ b/client-participation-alpha/src/components/topicAgenda/TopicAgenda.jsx
@@ -4,8 +4,9 @@ import { extractArchetypalComments } from "./utils/archetypeExtraction";
import LayerHeader from "./components/LayerHeader";
import ScrollableTopicsGrid from "./components/ScrollableTopicsGrid";
import TopicAgendaStyles from "./components/TopicAgendaStyles";
+import PolisNet from "../../lib/net";
-const TopicAgenda = ({ conversation, conversation_id }) => {
+const TopicAgenda = ({ conversation_id }) => {
const [loadWidget, setLoadWidget] = useState(false);
const [selections, setSelections] = useState(new Set());
const [commentMap, setCommentMap] = useState(new Map());
@@ -13,6 +14,7 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
const [reportData, setReportData] = useState({});
const [isLoading, setIsLoading] = useState(false);
const [err, setError] = useState(null);
+ const [conversation, setConversation] = useState(null);
const {
loading,
@@ -27,16 +29,13 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
const f = async () => {
// Check if topic prioritization is available for this conversation
try {
- const topicPrioritizeResponse = await fetch(
- `${import.meta.env.PUBLIC_SERVICE_URL}/participation/topicPrioritize?conversation_id=${conversation_id}`,
- {
- method: 'GET',
- credentials: 'include'
- }
+ const topicPrioritizeResponse = await PolisNet.polisGet(
+ '/participation/topicPrioritize',
+ { conversation_id }
);
- if (topicPrioritizeResponse.ok) {
- const topicPrioritizeData = await topicPrioritizeResponse.json();
+ if (topicPrioritizeResponse) {
+ const topicPrioritizeData = topicPrioritizeResponse;
console.log('Topic prioritize check:', topicPrioritizeData);
if (topicPrioritizeData.has_report && topicPrioritizeData.report_id) {
@@ -44,19 +43,21 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
report_id: topicPrioritizeData.report_id,
conversation_id: topicPrioritizeData.conversation_id
});
+ // Also fetch full convo with PCA (large query)
+ const convoFetcher = await PolisNet.polisGet('/participationInit', { conversation_id, includePCA: true });
+ if (convoFetcher) {
+ setConversation(convoFetcher);
+ }
// Also fetch comments for the TopicAgenda
// Use the original zinvite for the comments API, not the numeric conversation_id
- const commentsResponse = await fetch(
- `${import.meta.env.PUBLIC_SERVICE_URL}/comments?conversation_id=${conversation_id}&moderation=true&include_voting_patterns=true`,
- {
- method: 'GET',
- credentials: 'include'
- }
+ const commentsResponse = await PolisNet.polisGet(
+ '/comments',
+ { conversation_id, moderation: 'true', include_voting_patterns: 'true' }
);
- if (commentsResponse.ok) {
- const cd = await commentsResponse.json();
+ if (commentsResponse) {
+ const cd = commentsResponse;
console.log(`Found ${cd.length} comments for topic prioritization`);
setComments(cd);
}
@@ -102,19 +103,15 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
useEffect(() => {
const checkForData = async () => {
try {
- const topicPrioritizeResponse = await fetch(
- `${import.meta.env.PUBLIC_SERVICE_URL}/participation/topicPrioritize?conversation_id=${conversation_id}`,
- {
- method: 'GET',
- credentials: 'include'
- }
+ const topicPrioritizeResponse = await PolisNet.polisGet(
+ '/participation/topicPrioritize',
+ { conversation_id }
);
- if (topicPrioritizeResponse.ok) {
- const topicPrioritizeData = await topicPrioritizeResponse.json();
+ if (topicPrioritizeResponse) {
+ const topicPrioritizeData = topicPrioritizeResponse;
if (topicPrioritizeData.has_report && topicPrioritizeData.report_id) {
- fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/delphi?report_id=${topicPrioritizeData.report_id}`)
- .then((response) => response.json())
+ PolisNet.polisGet('/delphi', { report_id: topicPrioritizeData.report_id })
.then((response) => {
if (response && response.status === "success") {
if (response.runs && Object.keys(response.runs).length > 0) {
@@ -133,7 +130,9 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
setError("Failed to connect to the topicMod endpoint");
setIsLoading(false);
});
- }
+ } else {
+ setError("Failed to retrieve topic data");
+ }
}
} catch (error) {
setError("Failed to retrieve topic data");
@@ -146,17 +145,13 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
const loadPreviousSelections = async () => {
try {
- const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/topicAgenda/selections?conversation_id=${conversation.conversation_id}`, {
- method: 'GET',
- credentials: 'include'
- });
-
- const result = await response.json();
-
+ const result = await PolisNet.polisGet(
+ '/topicAgenda/selections',
+ { conversation_id: conversation.conversation_id }
+ );
if (result.status === 'success' && result.data) {
- // Convert stored selections back to topic keys
const storedSelections = new Set();
- result.data.archetypal_selections.forEach(selection => {
+ result.data.archetypal_selections.forEach((selection) => {
storedSelections.add(selection.topic_key);
});
setSelections(storedSelections);
@@ -209,20 +204,14 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
}))
}));
- // Send to API
- const response = await fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/topicAgenda/selections`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
+ // Send to API (token storage handled centrally in PolisNet)
+ const result = await PolisNet.polisPost(
+ '/topicAgenda/selections',
+ {
conversation_id: conversation.conversation_id,
- selections: apiSelections
- }),
- credentials: 'include'
- });
-
- const result = await response.json();
+ selections: apiSelections,
+ }
+ );
if (result.status === 'success') {
console.log('Topic agenda selections saved successfully:', result.data);
@@ -240,7 +229,7 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
console.log(`isLoading: ${isLoading}, err: ${err}, loadWidget: ${loadWidget}`)
- if (isLoading) {
+ if (isLoading || err) {
return null;
}
@@ -279,52 +268,55 @@ const TopicAgenda = ({ conversation, conversation_id }) => {
}
+ // Render conditionally after all hooks are called
+ let content = null;
+
if (error) {
- return (
-
-
-
+ content = (
+
);
- }
-
- if (comments.length > 0 && Object.keys(reportData).length > 0) {
- return (
-
-
-
-
-
-
-
-
-
-
+ } else if (comments.length > 0 && Object.keys(reportData).length > 0) {
+ content = (
+
+
+
+
+
+
+
+
-
);
}
- return null;
+ // Always return the same component structure
+ if (!content) return null;
+
+ return (
+
+ {content}
+
+
+ );
};
diff --git a/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx b/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx
index b03d48c73f..1494754774 100644
--- a/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx
+++ b/client-participation-alpha/src/components/topicAgenda/components/ScrollableTopicsGrid.jsx
@@ -11,16 +11,15 @@ const ScrollableTopicsGrid = ({
}) => {
const [visibleLayers, setVisibleLayers] = useState(new Set());
- if (!topicData || !hierarchyAnalysis) return null;
-
- const runKeys = Object.keys(topicData.runs);
- const firstRun = topicData.runs[runKeys[0]];
+ // Extract data with safe defaults to avoid early returns after hooks
+ const runKeys = topicData ? Object.keys(topicData.runs) : [];
+ const firstRun = runKeys.length > 0 ? topicData.runs[runKeys[0]] : null;
+ const topicsByLayer = firstRun?.topics_by_layer;
+ const layers = hierarchyAnalysis?.layers || [];
- if (!firstRun.topics_by_layer) return null;
-
// Get the two coarsest layers (highest numbers)
// sortedLayers is ordered from highest to lowest (e.g., [7, 6, 5, 4, 3, 2, 1, 0])
- const sortedLayers = [...hierarchyAnalysis.layers].sort((a, b) => b - a);
+ const sortedLayers = [...layers].sort((a, b) => b - a);
const coarsestLayer = sortedLayers[0]; // e.g., 7
const secondCoarsestLayer = sortedLayers[1]; // e.g., 6
@@ -64,7 +63,7 @@ const ScrollableTopicsGrid = ({
// ================================================================
useEffect(() => {
- if (!firstRun || !firstRun.topics_by_layer) return;
+ if (!firstRun || !topicsByLayer) return;
const newVisibleLayers = new Set();
@@ -75,7 +74,7 @@ const ScrollableTopicsGrid = ({
Array.from(selections).forEach(topicKey => {
// Find which layer this topic belongs to
for (const layerId of sortedLayers) {
- const topic = Object.values(firstRun.topics_by_layer[layerId] || {})
+ const topic = Object.values(topicsByLayer[layerId] || {})
.find(t => t.topic_key === topicKey);
if (topic) {
if (!selectionsByLayer.has(layerId)) {
@@ -101,10 +100,10 @@ const ScrollableTopicsGrid = ({
});
setVisibleLayers(newVisibleLayers);
- }, [selections, sortedLayers.join(','), !!firstRun]); // Stable dependencies
+ }, [selections, sortedLayers.join(','), !!firstRun, !!topicsByLayer]); // Stable dependencies
const renderLayerTopics = (layerId, layerLabel, parentLayerId = null) => {
- const allTopics = firstRun.topics_by_layer[layerId];
+ const allTopics = topicsByLayer?.[layerId];
if (!allTopics) return null;
let topicEntries;
@@ -115,7 +114,7 @@ const ScrollableTopicsGrid = ({
// Get selections from the parent layer
Array.from(selections).forEach(topicKey => {
- const topic = Object.values(firstRun.topics_by_layer[parentLayerId] || {})
+ const topic = Object.values(topicsByLayer[parentLayerId] || {})
.find(t => t.topic_key === topicKey);
if (topic) {
if (!selectionsByLayer.has(parentLayerId)) {
@@ -184,6 +183,11 @@ const ScrollableTopicsGrid = ({
return null; // No labels for deeper layers
};
+ // Return early after all hooks have been called
+ if (!topicData || !hierarchyAnalysis || !topicsByLayer) {
+ return null;
+ }
+
return (
diff --git a/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js b/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js
index 065e90227f..debf6ff9b2 100644
--- a/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js
+++ b/client-participation-alpha/src/components/topicAgenda/hooks/useTopicData.js
@@ -1,3 +1,4 @@
+import PolisNet from '../../../lib/net';
import { useState, useEffect, useCallback } from "react";
export const useTopicData = (reportId, load) => {
@@ -110,15 +111,15 @@ export const useTopicData = (reportId, load) => {
return groups;
};
- const fetchUMAPData = useCallback(async (conversation) => {
+ const fetchUMAPData = useCallback(async (conversation) => {
try {
const conversationId = conversation?.conversation_id || reportId;
console.log("Fetching UMAP data for spatial filtering...");
- const response = await fetch(
- `${import.meta.env.PUBLIC_SERVICE_URL}/topicMod/proximity?conversation_id=${conversationId}&layer_id=all`
+ const data = await PolisNet.polisGet(
+ '/topicMod/proximity',
+ { conversation_id: conversationId, layer_id: 'all' }
);
- const data = await response.json();
if (data.status === "success" && data.proximity_data) {
console.log(`Loaded ${data.proximity_data.length} UMAP points for spatial filtering`);
@@ -140,8 +141,7 @@ export const useTopicData = (reportId, load) => {
if (!reportId || load === false) return;
setLoading(true);
- fetch(`${import.meta.env.PUBLIC_SERVICE_URL}/delphi?report_id=${reportId}`)
- .then((response) => response.json())
+ PolisNet.polisGet('/delphi', { report_id: reportId })
.then((response) => {
console.log("TopicAgenda topics response:", response);
diff --git a/client-participation-alpha/src/env.d.ts b/client-participation-alpha/src/env.d.ts
new file mode 100644
index 0000000000..976ce1ac92
--- /dev/null
+++ b/client-participation-alpha/src/env.d.ts
@@ -0,0 +1,13 @@
+///
+
+interface ImportMetaEnv {
+ readonly PUBLIC_SERVICE_URL: string;
+ readonly PUBLIC_OIDC_CACHE_KEY_PREFIX: string;
+ readonly PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX: string;
+ readonly INTERNAL_SERVICE_URL: string;
+ // Add other environment variables as needed
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/client-participation-alpha/src/lib/auth.ts b/client-participation-alpha/src/lib/auth.ts
index fbfd8e257f..b766211cb4 100644
--- a/client-participation-alpha/src/lib/auth.ts
+++ b/client-participation-alpha/src/lib/auth.ts
@@ -1,10 +1,32 @@
+const oidcCacheKeyPrefix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_PREFIX;
+const oidcCacheKeyIdTokenSuffix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX;
+
+/**
+ * Helper function to get conversation ID from current URL path
+ * Handles URLs like /alpha/2demo or just /2demo
+ */
+export function getConversationIdFromUrl(): string | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const pathname = window.location.pathname;
+ // Match patterns like /alpha/2demo or just /2demo
+ // Conversation IDs start with a digit followed by alphanumeric chars
+ const match = pathname.match(/^\/(?:alpha\/)?([0-9][0-9A-Za-z]+)/);
+ if (match) {
+ return match[1];
+ }
+ return null;
+}
+
/**
* Decodes a JWT from localStorage without verifying its signature.
*
* @param {string} key The localStorage key where the JWT is stored.
* @returns {object|null} The decoded JWT payload as an object, or null if the token is not found or invalid.
*/
-export function getJwtPayload(key: string) {
+function _getJwtPayload(key: string) {
if (typeof window === 'undefined' || !window.localStorage) {
return null;
}
@@ -30,17 +52,37 @@ export function getJwtPayload(key: string) {
}
export function getConversationToken(conversation_id: string) {
- const token = getJwtPayload(`participant_token_${conversation_id}`);
- if (!token) {
- return getOidcToken();
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ const tokenKey = `participant_token_${conversation_id}`;
+ const rawToken = localStorage.getItem(tokenKey);
+
+ if (!rawToken) {
+ // No conversation-specific token, try OIDC
+ const oidcToken = _getOidcToken();
+ if (oidcToken) {
+ return { token: oidcToken };
+ }
+ return null;
}
- return token;
+
+ // Decode the JWT to get the payload
+ const payload = _getJwtPayload(tokenKey);
+ if (!payload) {
+ return null;
+ }
+
+ return {
+ token: rawToken,
+ ...payload
+ };
}
-const oidcCacheKeyPrefix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_PREFIX;
-const oidcCacheKeyIdTokenSuffix = import.meta.env.PUBLIC_OIDC_CACHE_KEY_ID_TOKEN_SUFFIX;
-
export function setJwtToken(token: string) {
+ if (typeof window === 'undefined') {
+ return;
+ }
try {
if (!token) {
console.warn("[PolisStorage] Attempted to set null/empty token");
@@ -58,8 +100,6 @@ export function setJwtToken(token: string) {
// Store as participant_token_{conversationId}
if (window.localStorage) {
window.localStorage.setItem(tokenKey, token);
- } else if (window.sessionStorage) {
- window.sessionStorage.setItem(tokenKey, token);
} else {
console.warn("[PolisStorage] No storage available for JWT token");
}
@@ -96,13 +136,10 @@ function _getOidcTokenFromStorage(storage: Storage) {
const parsed = JSON.parse(value);
// Check for expiry and access_token
if (
- parsed &&
- parsed.body &&
- parsed.body.access_token &&
- parsed.expiresAt &&
- parsed.expiresAt > Math.floor(Date.now() / 1000)
+ parsed?.access_token &&
+ parsed?.expires_at > Math.floor(Date.now() / 1000)
) {
- return parsed.body.access_token;
+ return parsed.access_token;
}
}
} catch (e) {
@@ -114,10 +151,24 @@ function _getOidcTokenFromStorage(storage: Storage) {
return null;
}
-export function getOidcToken() {
- let token = _getOidcTokenFromStorage(window.localStorage);
- if (!token) {
- token = _getOidcTokenFromStorage(window.sessionStorage);
+function _getOidcToken() {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ return _getOidcTokenFromStorage(window.localStorage);
+}
+
+/**
+ * Automatically extract and store JWT token from API response
+ * This should be called by the net module for all API responses
+ */
+export function handleJwtFromResponse(response: any): void {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (response && response.auth && response.auth.token) {
+ console.log("[Auth] JWT token found in response, storing...");
+ setJwtToken(response.auth.token);
}
- return token;
}
diff --git a/client-participation-alpha/src/lib/net.js b/client-participation-alpha/src/lib/net.js
deleted file mode 100644
index 44aadf1e61..0000000000
--- a/client-participation-alpha/src/lib/net.js
+++ /dev/null
@@ -1,205 +0,0 @@
-// Copyright (C) 2012-present, The Authors. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License, version 3, as published by the Free Software Foundation. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see
.
-
-const urlPrefix = `${document.location.protocol}//${document.location.host}/`
-const basePath = '';
-
-// Auth/OIDC token getter function - this should be set by the app when Auth is initialized
-let getOidcAccessToken = null;
-
-let authReady = false;
-let authReadyPromise = null;
-let authReadyResolve = null;
-
-// Create a promise that resolves when auth is ready
-const initAuthReadyPromise = () => {
- authReadyPromise = new Promise((resolve) => {
- authReadyResolve = resolve;
- });
-}
-
-// Initialize the promise immediately
-initAuthReadyPromise();
-
-export const setOidcTokenGetter = (getter) => {
- getOidcAccessToken = getter;
-
- if (getter) {
- // Auth is now ready
- authReady = true;
- if (authReadyResolve) {
- authReadyResolve();
- }
- } else {
- // Auth is being cleared, reset the ready state
- authReady = false;
- initAuthReadyPromise();
- }
-}
-
-// Store Auth hooks for login redirect
-let oidcLoginRedirect = null;
-
-export const setOidcActions = (actions) => {
- if (actions && typeof actions === 'object') {
- oidcLoginRedirect = actions.signinRedirect;
- } else {
- // Clear if null/undefined passed
- oidcLoginRedirect = null;
- }
-}
-
-// Export functions to check auth readiness
-export const isAuthReady = () => authReady;
-export const waitForAuthReady = () => authReadyPromise;
-
-const getAccessTokenSilentlySPA = async (options) => {
- // Wait for auth to be ready
- if (!authReady && authReadyPromise) {
- await authReadyPromise;
- }
-
- if (getOidcAccessToken) {
- try {
- const token = await getOidcAccessToken({
- cacheMode: 'on', // Use cached token if valid
- ...options
- })
- return token;
- } catch (e) {
- // Handle specific OIDC errors
- if (
- e.error === 'login_required' &&
- oidcLoginRedirect &&
- typeof oidcLoginRedirect === 'function'
- ) {
- oidcLoginRedirect();
- return null;
- }
-
- // Let the error bubble up to be handled by the calling code
- throw e;
- }
- } else {
- console.warn('⚠️ Token getter not available even after waiting');
- return Promise.resolve(undefined);
- }
-}
-
-// Request interceptor for handling auth errors
-const handleAuthError = (error, response) => {
- if (response && (response.status === 401 || response.status === 403)) {
- console.warn('Authentication/authorization error:', response.status);
- // For 401 (unauthorized), try to redirect to login
- if (response.status === 401) {
- // Check if we should force signout
- if (oidcLoginRedirect && typeof oidcLoginRedirect === 'function') {
- oidcLoginRedirect();
- return error;
- }
- }
- }
-
- throw error;
-}
-
-async function polisFetch(api, data, type) {
- if (typeof api !== 'string') {
- throw new Error('api param should be a string');
- }
-
- if (api && api.length && api[0] === '/') {
- api = api.slice(1);
- }
-
- let url = urlPrefix + basePath + api;
-
- const headers = {
- 'Content-Type': 'application/json; charset=utf-8',
- 'Cache-Control': 'max-age=0'
- };
-
- let body = null;
- let method = type ? type.toUpperCase() : 'GET';
-
- if (method === 'GET' && data && Object.keys(data).length > 0) {
- const queryParams = new URLSearchParams(data);
- url += `?${queryParams.toString()}`
- } else if ((method === 'POST' || method === 'PUT') && data && Object.keys(data).length > 0) {
- body = JSON.stringify(data);
- }
-
- try {
- const token = await getAccessTokenSilentlySPA();
-
- // Only add the header if a token exists
- if (token) {
- headers.Authorization = `Bearer ${token}`;
- }
- } catch (error) {
- // If getting the token fails, continue without it
- // The server will decide if auth is required
- console.warn('⚠️ Error getting access token:', error);
- }
-
- const response = await fetch(url, {
- method: method,
- headers: headers,
- body: body
- });
-
- if (!response.ok && response.status !== 304) {
- // Read the response body to include in the error
- const errorBody = await response.text()
- console.error('❌ API Error Response:', {
- status: response.status,
- statusText: response.statusText,
- body: errorBody
- });
-
- // Create a new error object and attach the response body
- const error = new Error(
- `Polis API Error: ${method} ${url} failed with status ${response.status} (${response.statusText})`
- );
- error.responseText = errorBody;
- error.status = response.status;
-
- return handleAuthError(error, response);
- }
-
- const jsonResponse = await response.json();
- return jsonResponse;
-}
-
-async function polisPost(api, data) {
- return await polisFetch(api, data, 'POST');
-}
-
-async function polisPut(api, data) {
- return await polisFetch(api, data, 'PUT');
-}
-
-async function polisGet(api, data) {
- try {
- const d = await polisFetch(api, data, 'GET');
- return d;
- } catch (error) {
- // If we have a 403, it might be the initial race condition. Retry once.
- if (error.status === 403) {
- console.warn('⚠️ Received 403 on GET, retrying request once after a short delay...');
- await new Promise((resolve) => setTimeout(resolve, 500)); // wait 500ms
- return await polisFetch(api, data, 'GET'); // This is the retry
- }
- // For other errors, or if retry fails, log and re-throw.
- console.error('❌ polisGet error:', error);
- throw error;
- }
-}
-
-const PolisNet = {
- polisFetch: polisFetch,
- polisPost: polisPost,
- polisPut: polisPut,
- polisGet: polisGet,
- getAccessTokenSilentlySPA
-}
-export default PolisNet
diff --git a/client-participation-alpha/src/lib/net.ts b/client-participation-alpha/src/lib/net.ts
new file mode 100644
index 0000000000..13fae7041a
--- /dev/null
+++ b/client-participation-alpha/src/lib/net.ts
@@ -0,0 +1,293 @@
+import { handleJwtFromResponse, getConversationToken, getConversationIdFromUrl } from './auth';
+
+// Simplified service base resolution (both env vars are required)
+const SERVICE_BASE: string = (
+ typeof window !== 'undefined'
+ ? import.meta.env.PUBLIC_SERVICE_URL
+ : import.meta.env.INTERNAL_SERVICE_URL
+ )?.replace(/\/$/, '') || '';
+
+// Default request timeout (ms)
+const REQUEST_TIMEOUT_MS: number = Number(import.meta.env.PUBLIC_REQUEST_TIMEOUT_MS) || 10000;
+
+// Optional base path (kept for compatibility; ensure it is normalized when used)
+const basePath: string = '';
+
+// Type definitions
+type OidcTokenGetter = (options?: { cacheMode?: string }) => Promise
;
+type OidcLoginRedirect = () => void;
+
+interface PolisApiError extends Error {
+ responseText?: string;
+ status?: number;
+}
+
+// Auth/OIDC token getter function - this should be set by the app when Auth is initialized
+let getOidcAccessToken: OidcTokenGetter | null = null;
+
+let authReady = false;
+let authReadyPromise: Promise | null = null;
+let authReadyResolve: ((value: void) => void) | null = null;
+
+// Create a promise that resolves when auth is ready
+const initAuthReadyPromise = () => {
+ authReadyPromise = new Promise((resolve) => {
+ authReadyResolve = resolve;
+ });
+}
+
+// Initialize the promise immediately
+initAuthReadyPromise();
+
+export const setOidcTokenGetter = (getter: OidcTokenGetter | null) => {
+ getOidcAccessToken = getter;
+
+ if (getter) {
+ // Auth is now ready
+ authReady = true;
+ if (authReadyResolve) {
+ authReadyResolve();
+ }
+ } else {
+ // Auth is being cleared, reset the ready state
+ authReady = false;
+ initAuthReadyPromise();
+ }
+};
+
+// Store Auth hooks for login redirect
+let oidcLoginRedirect: OidcLoginRedirect | null = null;
+
+interface OidcActions {
+ signinRedirect?: OidcLoginRedirect;
+}
+
+export const setOidcActions = (actions: OidcActions | null) => {
+ if (actions && typeof actions === 'object') {
+ oidcLoginRedirect = actions.signinRedirect || null;
+ } else {
+ // Clear if null/undefined passed
+ oidcLoginRedirect = null;
+ }
+};
+
+// Export functions to check auth readiness
+export const isAuthReady = () => authReady;
+export const waitForAuthReady = () => authReadyPromise;
+
+const getAccessTokenSilentlySPA = async (options?: { cacheMode?: string }): Promise => {
+ // On the server, skip OIDC entirely
+ if (typeof window === 'undefined') {
+ return undefined;
+ }
+
+ // If no getter is registered, skip immediately (do not wait)
+ if (!getOidcAccessToken) {
+ return undefined;
+ }
+
+ // Wait for auth to be ready
+ if (!authReady && authReadyPromise) {
+ await authReadyPromise;
+ }
+
+ if (getOidcAccessToken) {
+ try {
+ const token = await getOidcAccessToken({
+ cacheMode: 'on', // Use cached token if valid
+ ...options
+ });
+ return token;
+ } catch (e: any) {
+ // Handle specific OIDC errors
+ if (
+ e.error === 'login_required' &&
+ oidcLoginRedirect &&
+ typeof oidcLoginRedirect === 'function'
+ ) {
+ oidcLoginRedirect();
+ return null;
+ }
+
+ // Let the error bubble up to be handled by the calling code
+ throw e;
+ }
+ } else {
+ console.warn('⚠️ Token getter not available even after waiting');
+ return Promise.resolve(undefined);
+ }
+};
+
+// Request interceptor for handling auth errors
+const handleAuthError = (error: PolisApiError, response: Response): PolisApiError => {
+ if (response && (response.status === 401 || response.status === 403)) {
+ console.warn('Authentication/authorization error:', response.status);
+ // For 401 (unauthorized), try to redirect to login
+ if (response.status === 401) {
+ // Check if we should force signout
+ if (oidcLoginRedirect && typeof oidcLoginRedirect === 'function') {
+ oidcLoginRedirect();
+ return error;
+ }
+ }
+ }
+
+ throw error;
+};
+
+async function polisFetch(
+ api: string,
+ data?: Record,
+ type?: string
+): Promise {
+ if (typeof api !== 'string') {
+ throw new Error('api param should be a string');
+ }
+
+ // Build URL: allow absolute URLs; otherwise construct from origin/basePath and api path
+ let url: string;
+ const isAbsolute = /^(https?:)?\/\//i.test(api);
+ if (isAbsolute) {
+ url = api;
+ } else {
+ const apiPath = api.startsWith('/') ? api : `/${api}`;
+ url = `${SERVICE_BASE}${apiPath}`;
+ }
+
+ const headers: Record = {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Cache-Control': 'max-age=0'
+ };
+
+ let body: string | null = null;
+ let method = type ? type.toUpperCase() : 'GET';
+
+ if (method === 'GET' && data && Object.keys(data).length > 0) {
+ const queryParams = new URLSearchParams(data);
+ url += `?${queryParams.toString()}`
+ } else if ((method === 'POST' || method === 'PUT') && data && Object.keys(data).length > 0) {
+ body = JSON.stringify(data);
+ }
+
+ try {
+ // First try OIDC token
+ const oidcToken = await getAccessTokenSilentlySPA();
+ if (oidcToken) {
+ headers.Authorization = `Bearer ${oidcToken}`;
+ } else {
+ // Fall back to conversation-specific JWT if available
+ // Extract conversation_id from data or current URL path
+ let conversationId: string | null = null;
+
+ // First check if conversation_id is in the request data
+ if (data && (data as any).conversation_id) {
+ conversationId = (data as any).conversation_id;
+ } else if (typeof window !== 'undefined') {
+ // Try to extract from current page URL path using shared helper
+ conversationId = getConversationIdFromUrl();
+ }
+
+ if (conversationId) {
+ const conversationToken = getConversationToken(conversationId);
+ if (conversationToken && conversationToken.token) {
+ headers.Authorization = `Bearer ${conversationToken.token}`;
+ }
+ }
+ }
+ } catch (error) {
+ // If getting the token fails, continue without it
+ // The server will decide if auth is required
+ console.warn('⚠️ Error getting access token:', error);
+ }
+
+ console.log('🔍 Requesting:', {
+ url,
+ method,
+ headers,
+ body
+ });
+
+ // Add timeout to avoid indefinite hangs (especially during SSR)
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ method: method,
+ headers: headers,
+ body: body,
+ signal: controller.signal,
+ });
+ } catch (err: any) {
+ if (err && err.name === 'AbortError') {
+ const error: PolisApiError = new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${method} ${url}`);
+ (error as any).status = 408;
+ throw error;
+ }
+ throw err;
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ if (!response.ok && response.status !== 304) {
+ // Read the response body to include in the error
+ const errorBody = await response.text()
+ console.error('❌ API Error Response:', {
+ status: response.status,
+ statusText: response.statusText,
+ body: errorBody
+ });
+
+ // Create a new error object and attach the response body
+ const error: PolisApiError = new Error(
+ `Polis API Error: ${method} ${url} failed with status ${response.status} (${response.statusText})`
+ );
+ error.responseText = errorBody;
+ error.status = response.status;
+
+ // handleAuthError will throw, so this never returns normally
+ handleAuthError(error, response);
+ throw error; // TypeScript needs this for type checking even though it's unreachable
+ }
+
+ const jsonResponse = await response.json();
+
+ // Automatically handle JWT tokens in response
+ handleJwtFromResponse(jsonResponse);
+
+ return jsonResponse;
+}
+
+async function polisPost(api: string, data?: Record): Promise {
+ return await polisFetch(api, data, 'POST');
+}
+
+async function polisPut(api: string, data?: Record): Promise {
+ return await polisFetch(api, data, 'PUT');
+}
+
+async function polisGet(api: string, data?: Record): Promise {
+ try {
+ const d = await polisFetch(api, data, 'GET');
+ return d;
+ } catch (error: any) {
+ // If we have a 403, it might be the initial race condition. Retry once.
+ if (error.status === 403) {
+ console.warn('⚠️ Received 403 on GET, retrying request once after a short delay...');
+ await new Promise((resolve) => setTimeout(resolve, 500)); // wait 500ms
+ return await polisFetch(api, data, 'GET'); // This is the retry
+ }
+ // For other errors, or if retry fails, log and re-throw.
+ console.error('❌ polisGet error:', error);
+ throw error;
+ }
+}
+
+const PolisNet = {
+ polisFetch: polisFetch,
+ polisPost: polisPost,
+ polisPut: polisPut,
+ polisGet: polisGet,
+ getAccessTokenSilentlySPA
+}
+export default PolisNet
diff --git a/client-participation-alpha/src/pages/404.astro b/client-participation-alpha/src/pages/404.astro
new file mode 100644
index 0000000000..b3cc286fac
--- /dev/null
+++ b/client-participation-alpha/src/pages/404.astro
@@ -0,0 +1,47 @@
+---
+import Layout from './layouts/Layout.astro';
+import Header from '../components/Header.astro';
+import { getTranslations } from '../strings/strings.js';
+
+const s = await getTranslations();
+---
+
+
+
+
+
+
+
404 - Page Not Found
+
The page you're looking for doesn't exist.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client-participation-alpha/src/pages/[conversation_id].astro b/client-participation-alpha/src/pages/[conversation_id].astro
index 7b663fe93a..7dcbaa404b 100644
--- a/client-participation-alpha/src/pages/[conversation_id].astro
+++ b/client-participation-alpha/src/pages/[conversation_id].astro
@@ -5,15 +5,28 @@ import Survey from '../components/Survey.jsx';
import SurveyForm from '../components/SurveyForm.jsx';
import TopicAgenda from '../components/topicAgenda/TopicAgenda.jsx';
import { getTranslations } from '../strings/strings.js';
+import PolisNet from '../lib/net';
const { conversation_id } = Astro.params;
+
+// Validate conversation_id - reject common invalid requests
+if (!conversation_id ||
+ conversation_id === 'favicon.ico' ||
+ conversation_id === 'robots.txt' ||
+ conversation_id === 'sitemap.xml' ||
+ conversation_id.includes('.') || // Reject any ID with file extensions
+ conversation_id.length < 3) { // Reject very short IDs
+ return Astro.redirect('/404');
+}
+
let initialData;
let fetchError = null;
try {
- const apiParams = new URLSearchParams({
- conversation_id: conversation_id as string,
- });
+ const apiParams = new URLSearchParams();
+ if (conversation_id) {
+ apiParams.append('conversation_id', conversation_id);
+ }
const xid = Astro.url.searchParams.get('xid');
const x_name = Astro.url.searchParams.get('x_name');
@@ -31,38 +44,27 @@ try {
}
- const apiUrl = `${import.meta.env.INTERNAL_SERVICE_URL}/participationInit?conversation_id=${conversation_id}`;
-
- const response = await fetch(apiUrl);
- if (!response.ok) {
- throw new Error(`API request failed with status: ${response.status}`);
- }
- initialData = await response.json();
- if (initialData?.auth?.token) {
- // Store the token for later use
- try {
- const token = initialData.auth.token;
- const parts = token.split('.');
- if (parts.length === 3) {
- const payload = JSON.parse(atob(parts[1]));
- if (payload.conversation_id) {
- const tokenKey = "participant_token_" + payload.conversation_id;
- if (window.localStorage) {
- window.localStorage.setItem(tokenKey, token);
- } else if (window.sessionStorage) {
- window.sessionStorage.setItem(tokenKey, token);
- }
- } else {
- console.warn("[Index] No conversation_id in JWT payload, not storing token.");
- }
- }
- } catch (e) {
- console.error("[Index] Failed to store JWT token:", e);
- }
- }
-} catch (error) {
+ initialData = await PolisNet.polisGet('/participationInit', { conversation_id, includePCA: false });
+ // Note: JWT token handling will be done client-side since this is SSR
+ // The handleJwtFromResponse function will be called when the page hydrates
+} catch (error: unknown) {
console.error("Failed to fetch conversation data:", error);
- fetchError = "Could not load this conversation. Please check the ID and try again.";
+
+ // Type-safe error handling
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ const errorCause = error instanceof Error ? error.cause : undefined;
+ const errorCode = errorCause && typeof errorCause === 'object' && 'code' in errorCause
+ ? String(errorCause.code)
+ : undefined;
+
+ console.error("[DEBUG] Error details:", {
+ message: errorMessage,
+ cause: errorCause,
+ code: errorCode,
+ stack: error instanceof Error ? error.stack : undefined
+ });
+
+ fetchError = `Could not load this conversation. Error: ${errorMessage}. Please check the ID and try again.`;
}
const s = await getTranslations();
@@ -86,6 +88,16 @@ if (initialData) {
const hideHeader = Astro.url.searchParams.get('hide_header');
---
+ {/* Handle JWT token from initial data on client side */}
+
+
{hideHeader ? null :
}
@@ -106,7 +118,6 @@ const hideHeader = Astro.url.searchParams.get('hide_header');
<>
diff --git a/client-participation-alpha/src/pages/layouts/Layout.astro b/client-participation-alpha/src/pages/layouts/Layout.astro
index d45ebfa358..766cd4cee8 100644
--- a/client-participation-alpha/src/pages/layouts/Layout.astro
+++ b/client-participation-alpha/src/pages/layouts/Layout.astro
@@ -7,6 +7,7 @@ const { title = 'Polis Conversation' } = Astro.props;
+
{title}
diff --git a/client-participation-alpha/tsconfig.json b/client-participation-alpha/tsconfig.json
index 69c16003b9..d2a5794b54 100644
--- a/client-participation-alpha/tsconfig.json
+++ b/client-participation-alpha/tsconfig.json
@@ -9,6 +9,7 @@
],
"compilerOptions": {
"jsx": "react-jsx",
- "jsxImportSource": "react"
+ "jsxImportSource": "react",
+ "module": "esnext"
}
}
\ No newline at end of file
diff --git a/client-participation/js/stores/polis.js b/client-participation/js/stores/polis.js
index a09f03f8ff..4abd9d6e87 100644
--- a/client-participation/js/stores/polis.js
+++ b/client-participation/js/stores/polis.js
@@ -156,68 +156,6 @@ module.exports = function (params) {
return clusters;
}
- // TODO rename
- function syncAllCommentsForCurrentStimulus() {
- // more like sync?
- var dfd = $.Deferred();
- var params = {
- lastServerToken: new Date(0).getTime(),
- not_voted_by_pid: myPid,
- conversation_id: conversation_id
- //?
- };
-
- function fail() {
- dfd.reject(0);
- }
- getComments(params).then(
- function (comments) {
- if (!comments) {
- logger.log("no new comments for stimulus");
- dfd.resolve(0);
- return;
- }
- var IDs = _.map(comments, "tid");
- var oldkeys = _.keys(commentsToVoteOn).map(function (tid) {
- return parseInt(tid, 10);
- });
- var newIDs = _.difference(IDs, oldkeys);
- comments.forEach(function (ev) {
- var d = ev.created;
- if (d > lastServerTokenForComments) {
- lastServerTokenForComments = d;
- }
- });
- var newComments = comments.filter(function (ev) {
- return _.includes(newIDs, ev.tid);
- });
- for (var i = 0; i < newComments.length; i++) {
- var tid = newComments[i].tid;
- var alreadyVotedOn = !!votesByMe.findWhere({
- tid: tid
- });
- if (!alreadyVotedOn) {
- commentsToVoteOn[tid] = newComments[i];
- }
- }
- var numComments = _.keys(commentsToVoteOn).length;
- if (numComments) {
- commentsAvailableCallbacks.fire();
- dfd.resolve(numComments);
- } else {
- fail();
- }
- // }, fail);
- },
- function (err) {
- logger.error("failed to fetch comments");
- logger.dir(err);
- fail();
- }
- );
- return dfd.promise();
- }
-
function getNextComment(o) {
var params = {
not_voted_by_pid: myPid,
@@ -2137,7 +2075,6 @@ module.exports = function (params) {
invite: invite,
convSub: convSub,
queryParticipantsByMetadata: queryParticipantsByMetadata,
- syncAllCommentsForCurrentStimulus: syncAllCommentsForCurrentStimulus,
addInitReadyListener: initReadyCallbacks.add,
addAuthStatChangeListener: authStateChangeCallbacks.add,
removePersonUpdateListener: personUpdateCallbacks.remove,
diff --git a/client-report/REPORT_ROUTES.md b/client-report/REPORT_ROUTES.md
new file mode 100644
index 0000000000..2b27af4410
--- /dev/null
+++ b/client-report/REPORT_ROUTES.md
@@ -0,0 +1,151 @@
+# Polis Client Report Routes Directory
+
+This document provides a comprehensive overview of all available report routes in the Polis client-report application. All routes follow the pattern `/{routeType}/{report_id}`.
+
+## Available Routes
+
+### 1. `/report/{report_id}`
+**Component:** Standard Report (default)
+**Description:** The main comprehensive report view showing overview, groups, consensus statements, and participant statistics.
+**Features:**
+- Participant and vote statistics
+- Opinion group analysis
+- Consensus and divisive statements
+- Metadata and demographics
+
+### 2. `/narrativeReport/{report_id}`
+**Component:** NarrativeOverview
+**Description:** A narrative-style report presenting the conversation analysis in a more readable, story-like format.
+**Features:**
+- Narrative summaries of group perspectives
+- Key themes and insights
+- Contextual analysis of the conversation
+
+### 3. `/commentsReport/{report_id}`
+**Component:** CommentsReport
+**Description:** Detailed view of all comments in the conversation with voting patterns and analysis.
+**Features:**
+- Full list of all comments
+- Vote breakdowns (agree/disagree/pass)
+- Comment filtering and sorting
+- Consensus metrics for each comment
+
+### 4. `/topicReport/{report_id}`
+**Component:** TopicReport
+**Description:** Topic-based analysis showing AI-generated narrative summaries for different topics.
+**Features:**
+- Dropdown selector for different topics
+- Side-by-side narrative and cited comments
+- Cross-group consensus analysis
+- Topic-specific insights
+
+### 5. `/topicsVizReport/{report_id}`
+**Component:** TopicsVizReport
+**Description:** Visual representation of topics and their relationships.
+**Features:**
+- Interactive topic visualization
+- Topic clustering and relationships
+- Visual exploration of conversation themes
+
+### 6. `/exportReport/{report_id}`
+**Component:** ExportReport
+**Description:** Data export interface for downloading conversation data.
+**Features:**
+- Export conversation data in various formats
+- Download raw data for further analysis
+- Customizable export options
+
+### 7. `/topicPrioritize/{report_id}`
+**Component:** TopicPrioritize
+**Description:** Interface for prioritizing topics based on various metrics.
+**Features:**
+- Topic ranking and prioritization
+- Multi-criteria topic evaluation
+- Decision support for topic selection
+
+### 8. `/topicPrioritizeSimple/{report_id}`
+**Component:** TopicPrioritizeSimple
+**Description:** Simplified version of topic prioritization interface.
+**Features:**
+- Streamlined topic prioritization
+- Basic ranking functionality
+- User-friendly interface for quick decisions
+
+### 9. `/topicAgenda/{report_id}`
+**Component:** TopicAgenda
+**Description:** Agenda-building tool based on conversation topics.
+**Features:**
+- Create meeting agendas from topics
+- Organize discussion points
+- Export agenda items
+
+### 10. `/topicHierarchy/{report_id}`
+**Component:** TopicHierarchy
+**Description:** Hierarchical view of topics showing parent-child relationships.
+**Features:**
+- Tree-like topic structure
+- Topic dependencies and relationships
+- Drill-down navigation through topic levels
+
+### 11. `/topicStats/{report_id}`
+**Component:** TopicStats
+**Description:** Statistical analysis of topics with detailed metrics and visualizations.
+**Features:**
+- Topic statistics dashboard
+- Comment count and vote density per topic
+- Group-aware consensus metrics
+- Interactive visualizations (scatterplot, beeswarm)
+- Navigate to individual topic pages
+- Collective statement generation
+- Layer distribution analysis
+
+### 12. `/topicMapNarrativeReport/{report_id}`
+**Component:** TopicMapNarrativeReport
+**Description:** Combined view showing topic mapping with narrative reports.
+**Features:**
+- Overview statistics
+- Topic visualization integration
+- Narrative topic reports
+- Raw data export
+
+## Usage Examples
+
+```
+http://localhost:5010/report/2arcefpshi
+http://localhost:5010/commentsReport/2arcefpshi
+http://localhost:5010/topicStats/2arcefpshi
+http://localhost:5010/narrativeReport/2arcefpshi
+```
+
+## Route Detection
+
+The application uses pathname-based routing:
+1. Extracts the route type from the URL path
+2. Extracts the report_id from the URL path
+3. Renders the appropriate component based on the route type
+
+## Adding New Routes
+
+To add a new report route:
+1. Create your component in the appropriate directory
+2. Import it in `app.jsx`
+3. Add a new conditional block in the render logic:
+```javascript
+if (route_type === "yourNewRoute") {
+ console.log("RENDERING: YourNewComponent");
+ return (
+
+ );
+}
+```
+
+## Notes
+
+- All routes require a valid `report_id` parameter
+- Routes are case-sensitive
+- The default route (`/report/{report_id}`) shows the standard comprehensive report
+- Some routes may require additional data to be loaded (e.g., math object, comments)
+- Routes handle their own data fetching and error states
\ No newline at end of file
diff --git a/client-report/jest.config.js b/client-report/jest.config.js
index d44176cd88..5cd78ed622 100644
--- a/client-report/jest.config.js
+++ b/client-report/jest.config.js
@@ -89,7 +89,9 @@ const config = {
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
- // moduleNameMapper: {},
+ moduleNameMapper: {
+ "\\.(css|less|scss)$": "identity-obj-proxy",
+ },
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
diff --git a/client-report/package-lock.json b/client-report/package-lock.json
index b20eb3fb43..6a22979d21 100644
--- a/client-report/package-lock.json
+++ b/client-report/package-lock.json
@@ -48,6 +48,7 @@
"event-hooks-webpack-plugin": "^2.3.0",
"glob": "~10.3.3",
"html-webpack-plugin": "^5.5.3",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
@@ -6792,6 +6793,12 @@
"node": ">=0.8.0"
}
},
+ "node_modules/harmony-reflect": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+ "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+ "dev": true
+ },
"node_modules/has-ansi": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
@@ -7219,6 +7226,18 @@
"postcss": "^8.1.0"
}
},
+ "node_modules/identity-obj-proxy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+ "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
+ "dev": true,
+ "dependencies": {
+ "harmony-reflect": "^1.4.6"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
diff --git a/client-report/package.json b/client-report/package.json
index 294193c58a..98e9e6bab3 100644
--- a/client-report/package.json
+++ b/client-report/package.json
@@ -31,6 +31,7 @@
"event-hooks-webpack-plugin": "^2.3.0",
"glob": "~10.3.3",
"html-webpack-plugin": "^5.5.3",
+ "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-fetch-mock": "^3.0.3",
diff --git a/client-report/public/index.html b/client-report/public/index.html
index d7d24b9d53..3087a6e1d9 100644
--- a/client-report/public/index.html
+++ b/client-report/public/index.html
@@ -22,6 +22,7 @@
+