Bladeren bron

Remove unused amendment types and voting controls

master
Jared Bell 4 dagen geleden
bovenliggende
commit
d7f8b73878
84 gewijzigde bestanden met toevoegingen van 3599 en 520 verwijderingen
  1. +134
    -0
      .junie/plans/add-amendment-carousel-card.md
  2. +143
    -0
      .junie/plans/add-auth-header-to-gen-api.md
  3. +142
    -0
      .junie/plans/add-comment-area-proposal-detail.md
  4. +163
    -0
      .junie/plans/add-vote-amendment-parameters.md
  5. +211
    -0
      .junie/plans/componentize-home-view.md
  6. +181
    -0
      .junie/plans/highlight-user-proposal-and-amendment-votes.md
  7. +154
    -0
      .junie/plans/linkable-proposals-router-view.md
  8. +278
    -0
      .junie/plans/modernize-icon-system.md
  9. +177
    -0
      .junie/plans/persist-wallet-data-use-session.md
  10. +110
    -0
      .junie/plans/style-amendment-carousel-cards.md
  11. +1
    -1
      index.html
  12. +5
    -7
      src/App.vue
  13. +227
    -83
      src/components/AmendmentCarousel.vue
  14. +13
    -25
      src/components/BrandBar.vue
  15. +189
    -0
      src/components/CommentThread.vue
  16. +215
    -12
      src/components/ProposalDetail.vue
  17. +26
    -16
      src/components/ProposalVotingCard.vue
  18. +4
    -3
      src/components/Sidebar.vue
  19. +0
    -65
      src/components/VotingControls.vue
  20. +7
    -18
      src/components/icons/Abstain.vue
  21. +55
    -0
      src/components/icons/AppIcon.vue
  22. +8
    -7
      src/components/icons/Arrow.vue
  23. +7
    -18
      src/components/icons/Cancel.vue
  24. +7
    -18
      src/components/icons/Confirm.vue
  25. +10
    -15
      src/components/icons/DownArrow.vue
  26. +18
    -10
      src/components/icons/Download.vue
  27. +10
    -19
      src/components/icons/Filter.vue
  28. +40
    -0
      src/components/icons/IconBase.vue
  29. +18
    -25
      src/components/icons/Identification.vue
  30. +13
    -14
      src/components/icons/Menu.vue
  31. +16
    -0
      src/components/icons/Plus.vue
  32. +12
    -19
      src/components/icons/SpeechBubbles.vue
  33. +13
    -19
      src/components/icons/Stack.vue
  34. +10
    -15
      src/components/icons/ThumbsDown.vue
  35. +10
    -15
      src/components/icons/ThumbsUp.vue
  36. +45
    -0
      src/components/icons/index.ts
  37. +25
    -0
      src/components/icons/types.ts
  38. +3
    -3
      src/components/modals/AmendmentModal.vue
  39. +2
    -2
      src/components/modals/FilterModal.vue
  40. +2
    -1
      src/components/modals/Modal.vue
  41. +118
    -0
      src/components/modals/NewAmendmentModal.vue
  42. +1
    -1
      src/components/modals/ProposalModal.vue
  43. +4
    -25
      src/components/modals/RegistrationModal.vue
  44. +141
    -5
      src/composables/useSession.ts
  45. +24
    -0
      src/generated/typescript/index.ts
  46. +1
    -1
      src/generated/typescript/models/AddAmendmentRequest.ts
  47. +14
    -0
      src/generated/typescript/models/AddCommentRequest.ts
  48. +15
    -0
      src/generated/typescript/models/Amendment.ts
  49. +11
    -0
      src/generated/typescript/models/AmendmentParagraph.ts
  50. +10
    -0
      src/generated/typescript/models/AmendmentStatus.ts
  51. +12
    -0
      src/generated/typescript/models/AmendmentVote.ts
  52. +18
    -0
      src/generated/typescript/models/AmendmentWithParagraphs.ts
  53. +17
    -0
      src/generated/typescript/models/AmendmentWithSummary.ts
  54. +14
    -0
      src/generated/typescript/models/Claims.ts
  55. +17
    -0
      src/generated/typescript/models/CommentWithUser.ts
  56. +8
    -0
      src/generated/typescript/models/FreighterLoginResponse.ts
  57. +11
    -0
      src/generated/typescript/models/Organization.ts
  58. +1
    -0
      src/generated/typescript/models/Proposal.ts
  59. +13
    -0
      src/generated/typescript/models/ProposalVote.ts
  60. +8
    -0
      src/generated/typescript/models/RegisterOrganizationRequest.ts
  61. +5
    -1
      src/generated/typescript/models/RegisterRequest.ts
  62. +12
    -0
      src/generated/typescript/models/RegisteredUser.ts
  63. +16
    -0
      src/generated/typescript/models/SelectableAmendment.ts
  64. +15
    -0
      src/generated/typescript/models/SelectableComment.ts
  65. +1
    -0
      src/generated/typescript/models/SelectableProposal.ts
  66. +13
    -0
      src/generated/typescript/models/UserAmendmentVote.ts
  67. +19
    -0
      src/generated/typescript/models/UserProposalVotes.ts
  68. +9
    -0
      src/generated/typescript/models/VoteAmendmentRequest.ts
  69. +13
    -0
      src/generated/typescript/models/VoteAmendmentResponse.ts
  70. +9
    -0
      src/generated/typescript/models/VoteChoice.ts
  71. +10
    -0
      src/generated/typescript/models/VoteProposalRequest.ts
  72. +48
    -6
      src/generated/typescript/services/AmendmentService.ts
  73. +6
    -4
      src/generated/typescript/services/AuthService.ts
  74. +74
    -0
      src/generated/typescript/services/CommentService.ts
  75. +52
    -0
      src/generated/typescript/services/OrganizationService.ts
  76. +53
    -1
      src/generated/typescript/services/ProposalService.ts
  77. +2
    -2
      src/main.ts
  78. +0
    -30
      src/types/amendment.ts
  79. +38
    -0
      src/utils/auth.ts
  80. +35
    -0
      src/utils/comments.ts
  81. +1
    -8
      src/utils/eventBus.ts
  82. +4
    -4
      src/views/HomeView.vue
  83. +1
    -1
      src/views/ProposalView.vue
  84. +1
    -1
      tsconfig.app.tsbuildinfo

+ 134
- 0
.junie/plans/add-amendment-carousel-card.md Bestand weergeven

@@ -0,0 +1,134 @@
---
sessionId: session-261002-164508-dfif
---

# Requirements

### Overview & Goals
The objective of this task is to convert the placeholder `Add` element in `AmendmentCarousel.vue` into a fully styled, accessible "dummy" amendment card. Clicking this card launches a new amendment creation modal, allowing users to propose an amendment to the currently viewed proposal using their connected Stellar Freighter wallet.

### Scope
- **In Scope**:
- Transforming the existing placeholder `<article>` in `src/components/AmendmentCarousel.vue` into an attractive, interactive dummy amendment card.
- Creating `src/components/modals/NewAmendmentModal.vue` to capture amendment details (name/title and markdown content).
- Integrating `AmendmentService.addAmendment` with wallet authorization via `@stellar/freighter-api`.
- Refreshing the amendment list in `AmendmentCarousel.vue` upon successful submission.
- Supporting full keyboard navigation and accessible roles for the dummy amendment trigger.
- **Out of Scope**:
- Modifying backend API endpoints or database schemas (already provided via `AmendmentService`).
- Changing voting or confirm/reject approval flows for amendments.

### User Stories
- **As a contributor**, I want to click an "Add Amendment" card in the carousel so that I can easily propose a revision to the current proposal.
- **As a keyboard user**, I want to focus and activate the "Add Amendment" card with Enter or Space so that the interface is fully accessible.
- **As a proposer**, I want a clean modal form to enter the amendment title and body and sign with my wallet so that my submission is properly recorded.

### Functional Requirements
- **Card UI & Styling**: The dummy card must match the carousel dimensions (`w-36 md:w-72 lg:w-80 shrink-0`) with styling indicating an actionable create card (e.g. dashed border, centered plus icon, clear typography, and subtle hover animations).
- **Modal Triggering**: Clicking or pressing Enter/Space on the dummy card must open the `NewAmendmentModal` via `useModal().show()`.
- **Form Submission**:
- The modal must contain inputs for Amendment Name (`name`) and Amendment Description/Content (`content`).
- Submit button must be disabled until required fields are filled.
- On submit, fetch wallet address from `@stellar/freighter-api` and invoke `AmendmentService.addAmendment({ proposalId, creator, name, content })`.
- **Carousel Refresh**: Once submitted successfully, the modal closes and the amendment carousel automatically reloads its list to display the newly proposed amendment.

### Non-Functional Requirements
- **Consistency**: Visual style must align with the existing Tailwind palette (slate/amber/neutral tones) used in `ProposalDetail.vue` and `ProposalModal.vue`.
- **Accessibility**: Provide proper `role="button"`, `tabindex="0"`, `aria-label`, and keyboard event handlers.

# Technical Design

### Current Implementation
- `src/components/AmendmentCarousel.vue`: Renders existing amendments using horizontal scroll. Currently ends with a raw placeholder `<article class="...">Add</article>`.
- `src/components/modals/Modal.vue`: Base dialog component with backdrop, keyboard listeners (Esc), headers, and action buttons.
- `src/components/modals/ProposalModal.vue`: Example creation modal utilizing `Modal.vue`, `PostBuilder.vue`, `freighter-api`, and `useModal`.
- `src/composables/useModal.ts`: Controls active modal visibility and dynamic content via `show(component)` and `hide()`.
- `src/generated/typescript/services/AmendmentService.ts`: Provides `addAmendment(requestBody: AddAmendmentRequest)` and `listAmendments(proposalId)`.

### Key Decisions
1. **Dedicated `NewAmendmentModal.vue` Component**:
- *Approach*: Create a new modal component specifically for adding amendments rather than overloading the read-only `AmendmentModal.vue`.
- *Rationale*: Keeps concerns separated—`AmendmentModal` focuses on inspecting existing amendments while `NewAmendmentModal` handles form inputs, validation, and wallet signing.
2. **Context & Refresh Communication**:
- *Approach*: Pass proposal context to the modal and utilize an event emitter or bus notification (`eventBus` or direct callback) upon submission to trigger `getAmendments()` in `AmendmentCarousel.vue`.
- *Rationale*: Avoids full page reload and keeps the carousel responsive.
3. **Card Presentation**:
- *Approach*: Render the dummy card at the end of the carousel item list with dashed border (`border-2 border-dashed border-slate-300 hover:border-amber-400`), a plus icon from `src/components/icons/Plus.vue`, and descriptive text.
- *Rationale*: Makes it instantly recognizable as an action card while preserving the flow and scroll snapping of the carousel.

### Proposed Changes
- **`src/components/modals/NewAmendmentModal.vue`** (New File):
- Form layout with `name` input and `PostBuilder` (or markdown content area).
- Validation ensuring `name.trim().length > 0` and content presence.
- Submission handler calling `AmendmentService.addAmendment(...)` with Freighter address.
- Emits `created` event or publishes to `eventBus`.
- **`src/components/AmendmentCarousel.vue`** (Modified):
- Replace raw `Add` article with styled card component.
- Add `openNewAmendmentModal` handler invoking `show(NewAmendmentModal)`.
- Listen for amendment creation events to trigger `getAmendments()`.

### Architecture Diagram
```mermaid
graph LR
User[User] -->|Click / Keyboard| DummyCard[Dummy Card in Carousel]
DummyCard -->|useModal.show| NewModal[NewAmendmentModal]
NewModal -->|Sign & Submit| Freighter[Stellar Freighter Wallet]
NewModal -->|addAmendment| Backend[AmendmentService API]
Backend -->|Success| Refresh[Refresh Carousel]
```

### File Structure & Changes
- `src/components/modals/NewAmendmentModal.vue`: New creation modal component.
- `src/components/AmendmentCarousel.vue`: Updated dummy card markup, styling, and modal invocation.
- `src/composables/useModal.ts` (if needed): Context helpers for active proposal ID.

# Testing

### Validation Approach
Verify the feature through interactive testing and automated checks:
- Verify that the dummy card renders correctly within the carousel at different viewport sizes (mobile, tablet, desktop).
- Verify keyboard interactions (Tab navigation, Enter, Space keys) trigger the modal.
- Verify modal form validation (disabling submission on empty fields).
- Verify successful API submission payload structure and carousel refresh.

### Key Scenarios
1. **Card Rendering & Styling**:
- Dummy card appears at the end of the amendments carousel.
- Styles match design requirements (dashed border, centered icon, hover transitions).
2. **Opening the Modal**:
- Clicking the card opens `NewAmendmentModal`.
- Pressing Enter or Space when focused on the card opens `NewAmendmentModal`.
3. **Submitting New Amendment**:
- Fill in amendment title and body.
- Click "Submit Amendment" -> triggers Freighter authorization and calls `AmendmentService.addAmendment`.
- Modal closes automatically and the new amendment appears in the carousel list.

### Edge Cases
- **No Wallet Connected / Rejected Signing**: Display appropriate feedback if Freighter rejects connection or signing fails.
- **Empty or Whitespace-only Inputs**: Ensure submit button remains disabled until valid name and content are provided.
- **Carousel Scroll Recalculation**: Ensure `updateEdges()` executes after adding an amendment to update left/right navigation arrows.

# Delivery Steps

### ✓ Step 1: Create NewAmendmentModal component
A dedicated `NewAmendmentModal.vue` component is created and integrated with the modal system.

- Create `src/components/modals/NewAmendmentModal.vue` utilizing `<Modal>` for the dialog container.
- Implement title input with validation (`name`), content editor (using `PostBuilder` or markdown input), and submit action.
- Integrate with `@stellar/freighter-api` to retrieve the signer address and `AmendmentService.addAmendment` to dispatch the proposal amendment.
- Add error handling, loading state during submission, and automated modal dismissal on success.

### ✓ Step 2: Implement and style the dummy amendment card in AmendmentCarousel
The carousel placeholder is replaced with a styled, accessible "Add Amendment" dummy card that opens the modal.

- Update the dummy `<article>` in `src/components/AmendmentCarousel.vue` to match carousel card dimensions (`w-36 md:w-72 lg:w-80`) with dashed border and hover effects (`border-2 border-dashed border-slate-300 hover:border-amber-400 bg-slate-50/50 hover:bg-amber-50/60`).
- Embed the `Plus` icon (`src/components/icons/Plus.vue`), title ("Add Amendment"), and descriptive helper text.
- Add keyboard accessibility (`role="button"`, `tabindex="0"`, Enter/Space handlers) and click handler calling `useModal().show(NewAmendmentModal)`.
- Pass current `proposalId` context to the modal so new amendments are attached to the active proposal.

### ✓ Step 3: Wire submission refresh and carousel reactivity
Amendment creation triggers carousel list reload and seamlessly updates the UI.

- Emit an event or trigger `getAmendments()` in `AmendmentCarousel.vue` upon successful amendment submission.
- Ensure the newly added amendment appears in the carousel without requiring a full page refresh.
- Verify scrolling edge calculations (`updateEdges()`) update after new amendment addition.

+ 143
- 0
.junie/plans/add-auth-header-to-gen-api.md Bestand weergeven

@@ -0,0 +1,143 @@
---
sessionId: session-261005-185830-1pry
---

# Requirements

### Overview & Goals
The goal is to ensure that all generated API requests in the frontend application automatically and dynamically include the user's `Authorization: Bearer <token>` header once authenticated upon login. Currently, the OpenAPI configuration in `src/main.ts` statically evaluates `localStorage` once at application startup using an inconsistent key, preventing subsequent API calls made after login from being authenticated. By configuring `OpenAPI.TOKEN` with a dynamic token resolver in `src/main.ts` and standardizing token persistence across authentication flows, all generated API service calls (e.g. `ProposalService`, `AmendmentService`, `CommentService`, `AuthService`) will seamlessly include the active bearer token.

### Scope
- **In Scope**:
- Updating `src/main.ts` to configure `OpenAPI.TOKEN` with a dynamic token resolver function using `getAuthToken()` or `localStorage`.
- Standardizing the token storage key across `BrandBar.vue`, `src/utils/auth.ts`, and runtime configurations.
- Ensuring generated service requests (`openapi-typescript-codegen` client) dynamically attach the `Authorization: Bearer <token>` header on every HTTP request whenever a token is present.
- Verifying clean execution and graceful handling when a user is unauthenticated or logs out.
- **Out of Scope**:
- Changing backend JWT signing, expiration, or validation algorithms.
- Modifying `gen-api.sh` script (handled previously or independently).

### User Stories
- As an authenticated user, I want all my API actions (such as voting, creating proposals, adding amendments, and posting comments) to automatically include my bearer token so that the backend can authenticate and authorize my operations.
- As a user logging in via Freighter wallet, I want my session JWT to take effect immediately without requiring a full page refresh.
- As a developer, I want the generated OpenAPI client to dynamically resolve the current auth token on each request so that authentication state remains reactive and reliable.

### Functional Requirements
- **Dynamic Token Resolution**: Configure `OpenAPI.TOKEN` in `src/main.ts` as an asynchronous resolver function `async () => getAuthToken() || ''` (or `() => getAuthToken() ?? undefined`) so that `resolve(options, config.TOKEN)` evaluates per-request.
- **Header Injection**: Ensure `src/generated/typescript/core/request.ts` automatically attaches `Authorization: Bearer <token>` to request headers whenever `token` is non-empty.
- **Consistent Storage Keys**: Standardize token retrieval through `getAuthToken()` from `src/utils/auth.ts` (which checks `authToken` and `token` fallbacks in `localStorage`).
- **Reactivity on Login**: When `BrandBar.vue` completes `AuthService.loginFreighter`, stored token in `localStorage` is immediately picked up by subsequent API requests without reloading the page.
- **Safe Fallback for Unauthenticated Requests**: When no token exists in storage, `OpenAPI.TOKEN` resolves to empty/undefined and requests are sent without `Authorization` header, preventing malformed headers or runtime errors.

### Non-Functional Requirements
- **Performance**: Zero overhead beyond a single synchronous/cached `localStorage` read per outgoing request.
- **Reliability & Type Safety**: Preserve strict TypeScript types and compatibility with `openapi-typescript-codegen` generated core contracts.

# Technical Design

### Current Implementation
1. **`src/main.ts`**:
Currently sets static headers at module load time:
```typescript
OpenAPI.BASE = ''
OpenAPI.HEADERS = localStorage.getItem("token") ? {"Authorization": "Bearer " + localStorage.getItem("token")} : {};
```
**Issues**:
- `OpenAPI.HEADERS` is evaluated once when the bundle loads. If the user logs in afterwards, `OpenAPI.HEADERS` remains `{}`.
- It checks key `"token"`, while `BrandBar.vue` saves to `"authToken"`.
- It leaves `OpenAPI.TOKEN` `undefined`, bypassing the built-in token resolver in `request.ts`.

2. **`src/components/BrandBar.vue`**:
Upon connecting Freighter wallet:
```typescript
const jwt = await AuthService.loginFreighter({stellarAddress: addr.address});
try {
localStorage.setItem("authToken", jwt.token as string);
} catch (_) {}
```

3. **`src/utils/auth.ts`**:
Provides helper functions:
```typescript
export function getAuthToken(): string | null {
try {
return localStorage.getItem('authToken') || localStorage.getItem('token');
} catch {
return null;
}
}
```

4. **`src/generated/typescript/core/request.ts`**:
The generated client's `getHeaders()` function natively supports dynamic token resolvers:
```typescript
const [token, username, password, additionalHeaders] = await Promise.all([
resolve(options, config.TOKEN),
resolve(options, config.USERNAME),
resolve(options, config.PASSWORD),
resolve(options, config.HEADERS),
]);
...
if (isStringWithValue(token)) {
headers['Authorization'] = `Bearer ${token}`;
}
```

### Key Decisions
- **Use `OpenAPI.TOKEN` Resolver Function**: Instead of assigning static headers or managing manual interceptors, leverage `openapi-typescript-codegen`'s native `TOKEN` resolver `OpenAPI.TOKEN = async () => getAuthToken() || ''`. This ensures zero architectural changes to generated services and automatic per-request resolution.
- **Centralize Token Lookup in `src/utils/auth.ts`**: Use `getAuthToken()` in `src/main.ts` to maintain unified support for both `authToken` and `token` storage keys and graceful exception handling if storage is restricted.
- **Clean Up Redundant Imports and Static Headers**: Remove the broken `OpenAPI.HEADERS` static assignment and duplicate `./index.css` import in `src/main.ts`.

### Architecture Diagram
```mermaid
graph LR
User[User / Freighter Wallet] -->|Login| BrandBar[BrandBar.vue]
BrandBar -->|AuthService.loginFreighter| Backend[Backend API]
Backend -->|Return JWT Token| BrandBar
BrandBar -->|localStorage.setItem authToken| Storage[(localStorage)]
Services[Generated API Services\nProposal / Amendment / Comment] -->|__request| RequestCore[core/request.ts]
RequestCore -->|Resolve OpenAPI.TOKEN| AuthUtil[utils/auth.ts: getAuthToken]
AuthUtil -->|Read Token| Storage
RequestCore -->|Headers with Authorization: Bearer token| Backend
```

### Proposed Changes
1. **`src/main.ts`**:
- Import `getAuthToken` from `./utils/auth`.
- Set `OpenAPI.TOKEN = async () => getAuthToken() || ''` (or `() => getAuthToken() ?? undefined`).
- Remove obsolete `OpenAPI.HEADERS` static assignment.
- Clean up duplicate `import './index.css'`.

2. **`src/utils/auth.ts`**:
- Ensure `getAuthToken()` safely handles nulls and retrieves the active token.

3. **`src/components/BrandBar.vue`**:
- Ensure the token retrieved from `AuthService.loginFreighter` is consistently saved to `localStorage` under `authToken`.

### File Structure
- `src/main.ts` (modified: configure `OpenAPI.TOKEN` resolver)
- `src/components/BrandBar.vue` (verified / modified: ensure consistent token persistence on login)
- `src/utils/auth.ts` (verified / modified: token utility resolution)

### Risks
- **Storage Availability**: If local storage is disabled (e.g. private browsing restrictions), `getAuthToken()` returns `null` safely without unhandled exceptions.
- **Token Invalidation / Expiry**: If a token expires, `request.ts` will receive standard 401 response from backend, allowing frontend components to handle re-authentication.

# Testing

### Validation Approach
Verify that outgoing HTTP requests dispatched via generated API services include the `Authorization: Bearer <token>` header when authenticated, and omit it when unauthenticated.

### Key Scenarios
1. **Authenticated Requests After Login**:
- Connect Freighter wallet and login via `BrandBar.vue`.
- Verify that subsequent calls to `ProposalService.listProposals()`, `AmendmentService.listAmendments()`, `CommentService.addComment()`, etc. include `Authorization: Bearer <token>` in the request headers.
2. **Initial App Load with Pre-existing Token**:
- With a valid `authToken` already stored in `localStorage`, refresh the page.
- Verify that immediate API calls during initial mount include the bearer token.
3. **Unauthenticated Requests**:
- With empty `localStorage`, verify that public API requests execute without an `Authorization` header and without JavaScript runtime errors.

### Edge Cases
- Dynamic token change (e.g., logging in as a different user) is immediately reflected in the very next request without requiring application restart.
- Null or whitespace token values do not produce malformed `Authorization: Bearer ` headers.

+ 142
- 0
.junie/plans/add-comment-area-proposal-detail.md Bestand weergeven

@@ -0,0 +1,142 @@
---
sessionId: session-261005-172001-12ux
---

# Requirements

### Overview & Goals
The objective is to maximize user engagement and utility by introducing an inline "Add Comment" area within `ProposalDetail.vue`. By enabling direct inline commenting without modal disruptions, users can effortlessly discuss and provide feedback on governance proposals, reducing interaction friction and streamlining the feedback loop.

### Scope
- **In Scope**:
- Embedding an inline comment input area (textarea and action controls) directly inside the Comments section of `src/components/ProposalDetail.vue`.
- Integrating `CommentService.addComment` from `src/generated/typescript/services/CommentService.ts` to submit comments.
- Form validation (preventing empty/whitespace submissions) and loading/error states.
- Refreshing or updating the comment stream upon successful submission.
- Accessible styling consistent with Tailwind CSS guidelines and the existing proposal layout.
- **Out of Scope**:
- Modal-based comment workflows (explicitly avoided per task specification).
- Editing, deleting, or nesting/threading multi-level comments in this phase.
- Backend schema or API endpoint modifications.

### User Stories
- **As a stakeholder**, I want to post feedback directly beneath a proposal so that I can contribute to the discussion with minimal friction and no context-switching modals.
- **As a user**, I want visual feedback while my comment is being submitted (e.g., loading state, error alert on failure) so that I know the exact status of my action.
- **As a keyboard user**, I want full keyboard accessibility (tab navigation, clear focus states) so that I can participate efficiently without relying solely on mouse input.

### Functional Requirements
- **Inline Input Form**:
- A clean, expandable textarea with placeholder text (e.g., "Add to the discussion...") rendered directly within the `<section aria-labelledby="comments-heading">` block.
- An "Add Comment" (or "Post Comment") submit button and optional "Cancel" / "Clear" button.
- **Validation & Form State**:
- Disable the submit button when the textarea is empty, whitespace-only, or while a submission is in flight.
- Display clear error messaging if the network or API call fails.
- **API Dispatch**:
- Construct an `AddCommentRequest` with `proposalId`, `content`, and active/fallback `userId`.
- Invoke `CommentService.addComment(requestBody)`.
- On success, reset the input field and update the comment list so the new comment appears immediately.

### Non-Functional Requirements
- **Usability & Utility**: Minimal layout shift; smooth transition between idle, submitting, and submitted states.
- **Design Consistency**: Match the Tailwind CSS design language (slate borders, amber accents, clear typography) of `ProposalDetail.vue` and `NewAmendmentModal.vue`.
- **Accessibility**: Semantic HTML `<form>`, `<textarea>`, `<button>`, `<label>` with associated ARIA attributes for screen readers.

# Technical Design

### Current Implementation
- `src/components/ProposalDetail.vue`: Renders proposal details, amendments via `AmendmentCarousel.vue`, and voting cards. The comments section currently contains static markup with a hidden placeholder (`v-if="false"`).
- `src/generated/typescript/services/CommentService.ts`: Exports `CommentService` with:
- `addComment(requestBody: AddCommentRequest): CancelablePromise<SelectableComment>` (`POST /api/v1/comment/add`)
- `listComments(proposalId: number): CancelablePromise<Array<CommentWithUser>>` (`GET /api/v1/comments/list`)
- `src/generated/typescript/models/AddCommentRequest.ts`: Defines `{ content: string; proposalId: number; userId: number; }`.
- `src/generated/typescript/models/CommentWithUser.ts`: Defines comment shape with author metadata (`id`, `content`, `createdAt`, `username`, `fullName`, `userId`, `proposalId`).

### Key Decisions
1. **Inline Placement in ProposalDetail.vue**:
- *Approach*: Integrate the comment box directly below the comments header in `ProposalDetail.vue` instead of launching a modal.
- *Rationale*: Maximizes utility and minimizes UX friction by keeping the proposal text in view while writing feedback.
2. **Dynamic Comments Fetch & Immediate Refresh**:
- *Approach*: Load comments for `proposal.id` via `CommentService.listComments` and refresh/append on new comment creation.
- *Rationale*: Provides immediate confirmation to the user that their contribution was recorded without necessitating a full page reload.
3. **Robust Input Validation & Loading States**:
- *Approach*: Use reactive refs (`commentText`, `isSubmitting`, `errorMessage`) to gate form submission and provide actionable feedback.
- *Rationale*: Prevents accidental duplicate requests and ensures users are informed of network or validation issues.

### Proposed Changes
- **`src/components/ProposalDetail.vue`**:
- Import `CommentService`, `AddCommentRequest`, and `CommentWithUser` from `../generated/typescript`.
- Add reactive state: `newComment`, `isSubmitting`, `submitError`, `comments`, and `loadingComments`.
- Implement `fetchComments(proposalId)` on `onMounted` and on `watch(proposalId)`.
- Implement `handleAddComment()` to call `CommentService.addComment` and reset the form.
- Replace placeholder section with the inline comment submission form and existing comments list.

### Data Models / Contracts
```typescript
// Payload sent to CommentService.addComment
const requestBody: AddCommentRequest = {
proposalId: props.proposal.id,
content: commentText.value.trim(),
userId: activeUserId ?? 1, // Fallback/resolved user ID
};
```

### Architecture Diagram
```mermaid
graph TD
User[User] -->|Types comment & clicks Submit| CommentForm[Inline Comment Area in ProposalDetail]
CommentForm -->|addComment| CommentService[CommentService.addComment]
CommentService -->|POST /api/v1/comment/add| BackendAPI[Backend API]
BackendAPI -->|SelectableComment| CommentService
CommentService -->|Success / New Comment| StateUpdate[Update Local Comments State & Reset Form]
StateUpdate -->|Re-render| UI[Updated Discussion Stream]
```

### File Structure
- `src/components/ProposalDetail.vue` (Modified): Inline form, reactive state, and `CommentService` integration.
- `src/generated/typescript/services/CommentService.ts` (Referenced): Backend API client for comment actions.

### Risks & Mitigations
- **Network Failure or 400/500 Errors**: Capture API errors in a dedicated `errorMessage` ref and display an inline alert without losing user input.
- **Double Submissions**: Disable the submit button immediately when `isSubmitting` is true.

# Testing

### Validation Approach
Verification focuses on maximizing automated and interactive confidence across normal, boundary, and error conditions.

### Key Scenarios
1. **Inline Form Rendering**:
- Verify textarea and "Add Comment" button render cleanly within the comments section of `ProposalDetail.vue`.
- Verify placeholder text and accessible labels are present.
2. **Comment Submission Flow**:
- Enter valid comment text and submit.
- Verify `CommentService.addComment` is invoked with `{ proposalId, content, userId }`.
- Verify textarea is cleared upon successful submission and the discussion stream updates.
3. **Empty Input Prevention**:
- Ensure the submit button is disabled when the textarea is empty or contains only whitespace.
4. **Loading & Disabled States**:
- Verify the submit button reflects pending state and prevents concurrent submissions.

### Edge Cases
- **API Error / Server Failure**: Verify that error messages are displayed prominently inline without clearing the user's drafted text.
- **Proposal ID Changes**: Verify comments state is reset and re-fetched when switching between proposals.
- **Special Characters & Multiline Text**: Verify formatting and line breaks in comments are preserved.

# Delivery Steps

### ✓ Step 1: Create inline comment form UI and state management
Build the inline comment form UI within the Comments section of `ProposalDetail.vue`.

- Add reactive state variables for comment content (`commentText`), submission loading state (`isSubmitting`), error messages (`commentError`), and comments list (`comments`, `isLoadingComments`).
- Insert an inline form in the comments section with a styled `<textarea>` input, accessible labels, character/input guidance, and action controls (submit and cancel/reset buttons).
- Implement input validation so that empty or whitespace-only submissions are disabled.
- Add comment list rendering within the comments section using `CommentService.listComments(proposal.id)` on mount and when proposal changes, providing immediate visual feedback for existing discussions.

### ✓ Step 2: Integrate CommentService API submission and discussion refresh
Connect the inline form to `CommentService.addComment` to persist user comments and refresh the discussion stream.

- Implement the `submitComment` async handler calling `CommentService.addComment({ proposalId: proposal.id, content: commentText.value.trim(), userId })`.
- Include robust error handling to display user-friendly inline error alerts if the API request fails.
- Clear the input textarea upon successful comment submission.
- Re-fetch or append the new comment into the local comments list so users immediately see their contribution without a full page reload.
- Verify type checks and build via `vue-tsc -b` to guarantee seamless integration and zero regressions.

+ 163
- 0
.junie/plans/add-vote-amendment-parameters.md Bestand weergeven

@@ -0,0 +1,163 @@
---
sessionId: session-261005-174554-ealk
---

# Requirements

### Overview & Goals
The objective is to dynamically resolve and read the current user's ID from their JWT authentication token when voting on amendments in `src/components/AmendmentCarousel.vue`. This replaces hardcoded placeholder user IDs (`userId: 1`) with genuine authenticated user context to ensure reliable vote attribution and system integrity.

### Scope
- **In Scope**:
- Implement a dedicated JWT decoding and auth helper utility (`src/utils/auth.ts`) to extract payload claims (specifically `userId`, `user_id`, or `sub`) from stored tokens in `localStorage` (`authToken` / `token`).
- Update `confirm` and `reject` voting methods in `src/components/AmendmentCarousel.vue` to dynamically retrieve the authenticated user ID.
- Handle edge cases gracefully when the JWT is absent, expired, or missing user identification.
- **Out of Scope**:
- Backend token signing or signature verification algorithms (handled on the server).
- Redesigning unrelated UI flows or authentication modals.

### User Stories
- As a logged-in user, I want my votes on amendments to be recorded under my actual account ID decoded from my session JWT so that my voting contributions are correctly counted.
- As an unauthenticated or expired user, I want voting attempts to fail gracefully with informative feedback rather than submitting fraudulent or invalid IDs.

### Functional Requirements
- **JWT Claim Extraction**: Safely decode Base64Url-encoded JWT tokens stored in `localStorage` (`authToken` and `token` fallbacks) without third-party runtime bloat.
- **User ID Resolution**: Extract the numerical user identifier from `userId`, `user_id`, or `sub` claims in the token payload.
- **Dynamic Vote Submission**: In `AmendmentCarousel.vue`, invoke `AmendmentService.voteAmendment` with the decoded `userId`.
- **Missing Token Handling**: If no valid user ID can be resolved, log a clear warning or error and prevent submitting invalid voting requests.

# Technical Design

### Current Implementation
In `src/components/BrandBar.vue`, upon Freighter authentication, the token is stored:
```typescript
const jwt = await AuthService.loginFreighter({ stellarAddress: addr.address });
localStorage.setItem("authToken", jwt.token as string);
```
In `src/components/AmendmentCarousel.vue`, `confirm` and `reject` currently use a hardcoded `userId: 1`:
```typescript
const confirm = async (amendmentId: number) => {
try {
await AmendmentService.voteAmendment({
amendmentId,
inFavor: true,
userId: 1, // Hardcoded placeholder
});
await getAmendments();
} catch (e) {
console.error("Failed to confirm amendment:", e);
}
};
```

### Key Decisions
- **Lightweight Client-Side JWT Parser**: Implement a lightweight decoding utility in `src/utils/auth.ts` using native Base64Url decoding (`atob` / `decodeURIComponent`) to minimize dependencies while ensuring high performance.
- **Flexible Token Key Resolution**: Check `localStorage.getItem("authToken")` with fallback to `localStorage.getItem("token")` to ensure seamless compatibility across all authentication paths.
- **Graceful Error Handling**: If no valid user ID is present in the token, log an error / set component error state and avoid making a malformed API call.

### Proposed Changes

1. **`src/utils/auth.ts`**:
- Create utility functions:
```typescript
export interface JwtPayload {
userId?: number;
user_id?: number;
sub?: string | number;
stellarAddress?: string;
exp?: number;
[key: string]: unknown;
}

export function parseJwt(token: string): JwtPayload | null {
try {
const base64Url = token.split('.')[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}

export function getAuthToken(): string | null {
try {
return localStorage.getItem('authToken') || localStorage.getItem('token');
} catch {
return null;
}
}

export function getUserIdFromJwt(): number | undefined {
const token = getAuthToken();
if (!token) return undefined;
const payload = parseJwt(token);
if (!payload) return undefined;
const rawId = payload.userId ?? payload.user_id ?? payload.sub;
const parsedId = Number(rawId);
return !isNaN(parsedId) && parsedId > 0 ? parsedId : undefined;
}
```

2. **`src/components/AmendmentCarousel.vue`**:
- Import `getUserIdFromJwt` from `../utils/auth.ts`.
- Update `confirm` and `reject`:
```typescript
const confirm = async (amendmentId: number) => {
const userId = getUserIdFromJwt();
if (!userId) {
console.error("User is not authenticated or user ID is missing from JWT.");
error.value = "Please connect your wallet to vote on amendments.";
return;
}
try {
await AmendmentService.voteAmendment({
amendmentId,
inFavor: true,
userId,
});
await getAmendments();
} catch (e) {
console.error("Failed to confirm amendment:", e);
}
};
```
- Apply matching logic for `reject(amendmentId: number)`.

### File Structure
- **New File**: `src/utils/auth.ts` (JWT extraction and user identification helpers)
- **Modified File**: `src/components/AmendmentCarousel.vue` (Dynamic user ID in voting handlers)

# Testing

### Validation Approach
- Execute TypeScript diagnostics to ensure strict type compliance.
- Verify JWT decoding behavior against standard token formats, invalid tokens, and missing tokens.

### Key Scenarios
1. **Valid JWT Present**: When `authToken` contains a valid JWT with `userId: 42`, `confirm` and `reject` pass `userId: 42` to `AmendmentService.voteAmendment`.
2. **Alternative Claims**: When JWT contains `user_id` or `sub`, the parser accurately extracts the numeric user ID.
3. **No Token / Invalid Token**: When no token exists or the token is malformed, voting is blocked gracefully with appropriate user/console error messaging.

# Delivery Steps

### ✓ Step 1: Implement JWT decoding and auth helper utility
Create robust utility functions to read and decode JWT claims from localStorage.

- Create `src/utils/auth.ts` with `parseJwt`, `getAuthToken`, and `getUserIdFromJwt`.
- Support standard Base64Url decoding with UTF-8 character safety.
- Extract `userId`, `user_id`, and `sub` claims, validating numeric output.

### ✓ Step 2: Connect dynamic user ID resolution in AmendmentCarousel
Update vote submission handlers to use the decoded user ID from the active JWT session.

- Import `getUserIdFromJwt` in `src/components/AmendmentCarousel.vue`.
- Update `confirm` to obtain the active user ID and pass it in `VoteAmendmentRequest`.
- Update `reject` to obtain the active user ID and pass it in `VoteAmendmentRequest`.
- Add validation and user feedback when the user is not authenticated.

+ 211
- 0
.junie/plans/componentize-home-view.md Bestand weergeven

@@ -0,0 +1,211 @@
---
sessionId: session-261002-153750-168j
---

# Requirements

### Overview & Goals
`HomeView.vue` currently acts as a monolithic view holding layout orchestration, proposal reading and paragraph rendering, amendment carousel presentation, comments placeholder, voting action buttons, export-to-markdown generation and file downloading, and empty/loading states.

The goal is to componentize `HomeView.vue` into modular, single-responsibility Vue 3 components and shared utility functions. This reduces template verbosity, enhances maintainability and testability, eliminates duplicated markdown parsing across the project, and establishes clean component boundaries.

### Scope
#### In Scope
- Extracting standalone proposal components:
- `ProposalDetail.vue`: Primary article layout combining proposal header, formatted paragraphs, amendments section, comments section, and the action rail.
- `ProposalVotingCard.vue`: Voting options card (Approve, Reject, Abstain).
- `ProposalExportCard.vue`: Markdown export card with download trigger.
- `ProposalEmptyState.vue`: Informational empty state when no proposal is selected.
- Extracting reusable utilities:
- `src/utils/markdown.ts`: Centralized markdown parsing and DOM sanitization.
- `src/utils/proposalExport.ts`: Proposal Markdown file generator and file download trigger.
- Refactoring `src/views/HomeView.vue` to orchestrate layout, data fetching, and loading states cleanly.
- Updating `ProposalContainer.vue` to use the shared markdown utility.

#### Out of Scope
- Backend API schema changes or new endpoints.
- Redesigning the visual styles or changing Tailwind classes and responsive behavior.
- Modifying `Sidebar.vue` or modal dialogs beyond their existing integration.

### User Stories
- **As a developer**, I want `HomeView.vue` to be concise and focused on high-level layout so that I can easily navigate and maintain page-level logic.
- **As a developer**, I want proposal voting, export, and presentation logic split into dedicated components so that they can be tested and modified independently without risking regressions across the entire page.
- **As a user**, I want proposal display, voting, and file downloading to function seamlessly with identical UI and responsive interactions.

### Functional Requirements
- **FR-1**: When no proposal is selected, the application displays `ProposalEmptyState` with the title "Select a proposal" and helper text.
- **FR-2**: When a proposal is selected, `ProposalDetail` renders:
- Proposal header with tags, creator wallet address, and proposal name.
- Proposal paragraphs formatted using sanitized Markdown.
- `AmendmentCarousel` with the current proposal ID.
- Comments section placeholder.
- Right-hand action rail containing `ProposalVotingCard` and `ProposalExportCard`.
- **FR-3**: Clicking the download button in `ProposalExportCard` triggers the Markdown export and downloads a sanitized `.md` file containing proposal title, author, and paragraphs.
- **FR-4**: Voting actions in `ProposalVotingCard` maintain their existing UI styling and emit selection events.
- **FR-5**: Sidebar visibility toggles and loading spinners remain fully operational across desktop and mobile viewports.

### Non-Functional Requirements
- **Maintainability**: Clear separation of presentation, business logic, and file utilities.
- **Type Safety**: Full TypeScript typing for props, emits, and utility functions conforming to generated API models.
- **Performance**: Zero overhead from component decomposition; no extra re-renders or layout shifts.

# Technical Design

### Current Implementation
`HomeView.vue` (245 lines) contains:
- Primary container with responsive sidebar integration and mobile backdrop.
- Proposal article layout with header, paragraph loops using inline `DOMPurify` / `marked` formatting, `AmendmentCarousel`, and comments container.
- Aside rail containing voting buttons and the markdown download card.
- Empty selection state.
- Script containing proposal data fetching, markdown sanitization, file download generation, loading states, and sidebar toggling.
- `ProposalContainer.vue` also duplicates markdown parsing logic (`marked` + `DOMPurify`).

### Key Decisions
1. **Component Hierarchy**:
- `HomeView.vue` remains the top-level route coordinator: handles sidebar toggle state, fetching proposals list, and active proposal selection.
- `ProposalDetail.vue` acts as the container for proposal inspection: receives `proposal: ProposalWithParagraphs` as a prop and embeds `AmendmentCarousel`, `ProposalVotingCard`, and `ProposalExportCard`.
- `ProposalVotingCard.vue` and `ProposalExportCard.vue` are extracted as dedicated components to keep the voting workflow and export logic decoupled from reading layout.
- `ProposalEmptyState.vue` handles the "no proposal selected" display.

2. **Utility Extraction (`src/utils/markdown.ts` & `src/utils/proposalExport.ts`)**:
- Extract `applyFormatting` / `renderMarkdown` into `src/utils/markdown.ts` to prevent code duplication between `HomeView.vue` and `ProposalContainer.vue`.
- Extract `downloadProposalMarkdown` and `sanitizeFilename` into `src/utils/proposalExport.ts` so file generation logic is pure, reusable, and easily testable.

### Components
- **`src/views/HomeView.vue` (Modified)**:
- Retains: `Sidebar`, mobile overlay, loading spinner, selection state, and window resize listeners.
- Uses: `<ProposalDetail :proposal="selected" />` and `<ProposalEmptyState />`.
- **`src/components/ProposalDetail.vue` (New)**:
- Props: `proposal: ProposalWithParagraphs`.
- Template: Proposal header, sanitized paragraph list, `AmendmentCarousel`, comments block, and sidebar rail embedding `ProposalVotingCard` and `ProposalExportCard`.
- **`src/components/ProposalVotingCard.vue` (New)**:
- Props: `proposalId: number`.
- Emits: `vote: [type: 'Positive' | 'Negative' | 'Abstain']`.
- Template: "Cast your vote" card with Approve, Reject, Abstain buttons.
- **`src/components/ProposalExportCard.vue` (New)**:
- Props: `proposalId: number`, `proposalName: string`.
- Template: Proposal record card with `.md` badge and download action button.
- **`src/components/ProposalEmptyState.vue` (New)**:
- Template: Centered container with "Select a proposal" header and instructions.
- **`src/components/ProposalContainer.vue` (Modified)**:
- Refactored to import `renderMarkdown` from `src/utils/markdown.ts`.

### File Structure
```
src/
├── components/
│ ├── AmendmentCarousel.vue (existing)
│ ├── LoadingSpinner.vue (existing)
│ ├── ProposalContainer.vue (modified - use markdown util)
│ ├── ProposalDetail.vue (new)
│ ├── ProposalEmptyState.vue (new)
│ ├── ProposalExportCard.vue (new)
│ ├── ProposalVotingCard.vue (new)
│ └── Sidebar.vue (existing)
├── utils/
│ ├── markdown.ts (new)
│ ├── proposalExport.ts (new)
│ └── wallet.ts (existing)
└── views/
└── HomeView.vue (modified - simplified coordinator)
```

### Architecture Diagram
```mermaid
graph TD
HomeView["HomeView.vue (Coordinator)"]
Sidebar["Sidebar.vue"]
ProposalEmptyState["ProposalEmptyState.vue"]
ProposalDetail["ProposalDetail.vue"]
AmendmentCarousel["AmendmentCarousel.vue"]
ProposalVotingCard["ProposalVotingCard.vue"]
ProposalExportCard["ProposalExportCard.vue"]
MarkdownUtil["utils/markdown.ts"]
ExportUtil["utils/proposalExport.ts"]
HomeView --> Sidebar
HomeView --> ProposalEmptyState
HomeView --> ProposalDetail
ProposalDetail --> AmendmentCarousel
ProposalDetail --> ProposalVotingCard
ProposalDetail --> ProposalExportCard
ProposalDetail --> MarkdownUtil
ProposalExportCard --> ExportUtil
```

### Data Models / Contracts
```typescript
// src/utils/markdown.ts
export function renderMarkdown(markdown?: string | null): string;

// src/utils/proposalExport.ts
export function sanitizeFilename(name: string): string;
export async function downloadProposalMarkdown(proposalId: number): Promise<void>;
```

### Risks
- **Styling or Layout Breakage**: Moving aside rail or article container into subcomponents could disrupt CSS grid or sticky positioning.
- *Mitigation*: Preserve exact Tailwind classes (`grid xl:grid-cols-[minmax(0,1fr)_15rem]`, `xl:sticky xl:top-6`) on the container and child components.
- **Event / Props Wiring**: Ensure proposal ID and markdown download methods properly receive required props.
- *Mitigation*: Strict TypeScript interfaces for all component props.

# Testing

### Validation Approach
Verify the refactored components through type checking, build verification, and functional validation against key user workflows.

### Key Scenarios
1. **Initial Page Load & Empty State**:
- On navigating to `/`, proposals list loads in sidebar.
- Main content area displays `ProposalEmptyState` ("Select a proposal").
2. **Proposal Selection**:
- Clicking a proposal in the sidebar loads details and renders `ProposalDetail`.
- Title, author badge, creator wallet, and paragraphs are displayed with correct markdown formatting.
- `AmendmentCarousel` receives the selected proposal ID and displays amendments.
3. **Proposal Export / Download**:
- Clicking the download button in `ProposalExportCard` generates and downloads the formatted `.md` file with sanitized filename.
4. **Voting Actions**:
- Approve, Reject, and Abstain buttons in `ProposalVotingCard` render properly with hover and focus states.
5. **Mobile Responsiveness**:
- Sidebar toggle and overlay function on small screens; sidebar closes upon selecting a proposal.

### Edge Cases
- **Missing or Empty Proposal Paragraphs**: `ProposalDetail` handles empty paragraph list gracefully without throwing runtime errors.
- **Unsafe HTML in Markdown**: `DOMPurify` via `markdown.ts` sanitizes all rendered HTML in both `ProposalDetail` and `ProposalContainer`.
- **Special Characters in Proposal Title**: `sanitizeFilename` replaces forbidden filesystem characters with dashes before triggering file download.

### Test Changes
- Execute `npm run build` (`vue-tsc -b && vite build`) to ensure 0 TypeScript or template compilation errors.

# Delivery Steps

### ✓ Step 1: Extract markdown formatting and export utilities
Create shared utilities to eliminate duplicate markdown handling and file export logic across components.

- Create `src/utils/markdown.ts` exporting `renderMarkdown` / `applyFormatting` using `marked` and `DOMPurify`.
- Create `src/utils/proposalExport.ts` to encapsulate the Markdown file generator, string sanitization (`sanitizeFilename`), and browser download trigger.
- Refactor `src/components/ProposalContainer.vue` to use the shared markdown helper instead of local inline parsing.

### ✓ Step 2: Implement standalone proposal action and state components
Create focused components for proposal actions and state placeholders.

- Create `src/components/ProposalVotingCard.vue` containing the "Cast your vote" card with Approve, Reject, and Abstain action buttons and emit events.
- Create `src/components/ProposalExportCard.vue` containing the Markdown download action and integration with `proposalExport.ts`.
- Create `src/components/ProposalEmptyState.vue` displaying the empty selection message and guidance when no proposal is active.

### ✓ Step 3: Implement ProposalDetail component
Encapsulate proposal content presentation into a dedicated detail component.

- Create `src/components/ProposalDetail.vue` accepting a `proposal: ProposalWithParagraphs` prop.
- Include the proposal header (tags, creator attribution, title), content body rendering paragraphs via `applyFormatting`, `AmendmentCarousel` integration, and comments section.
- Compose `ProposalVotingCard` and `ProposalExportCard` into the sticky action sidebar rail.

### ✓ Step 4: Refactor HomeView and verify integration
Streamline HomeView to act purely as the top-level page orchestrator.

- Replace inline proposal markup and action rail in `src/views/HomeView.vue` with `ProposalDetail` and `ProposalEmptyState`.
- Clean up unused imports, methods (`applyFormatting`, `downloadProposalMarkdown`, `sanitizeFilename`), and redundant styles from `HomeView.vue`.
- Verify TypeScript compilation and runtime behavior with `vue-tsc -b` and `vite build`.

+ 181
- 0
.junie/plans/highlight-user-proposal-and-amendment-votes.md Bestand weergeven

@@ -0,0 +1,181 @@
---
sessionId: session-261005-184808-15gg
---

# Requirements

### Overview & Goals
Provide immediate, clear visual feedback to users regarding their previous voting decisions on any visited proposal and its associated amendments. By leveraging `ProposalService.getUserVotes`, the application eliminates cognitive friction, prevents redundant vote attempts, and enhances user confidence by highlighting previously selected voting choices across proposal and amendment action controls.

### Scope
- **In Scope**:
- Fetching user vote records on proposal load using `ProposalService.getUserVotes(proposalId)`.
- Handling authenticated and unauthenticated states gracefully without blocking proposal content.
- Updating `ProposalVotingCard.vue` to highlight the user's previous proposal vote (`Approve`, `Reject`, or `Abstain`) with active colored styling and accessible state.
- Updating `AmendmentCarousel.vue` to color the quick-action approval (`Confirm`) and disapproval (`Cancel`) icons according to the user's vote on each amendment.
- Synchronizing vote indicators immediately upon casting a new proposal or amendment vote.
- **Out of Scope**:
- Modifying backend OpenAPI endpoints or database schemas.
- Adding vote retraction/deletion workflows beyond standard vote casting.

### User Stories
- **As a governance participant**, I want to see my existing votes highlighted when I visit a proposal so that I immediately know whether I have already participated and what position I took.
- **As a voter evaluating amendments**, I want the approval/disapproval icons on each amendment card to reflect my past votes so that I can quickly review my stance without opening each amendment individually.

### Functional Requirements
- **Data Retrieval**:
- When navigating to a proposal, `ProposalDetail.vue` must call `ProposalService.getUserVotes(proposalId)` to fetch all proposal and amendment votes cast by the current user.
- Unauthenticated requests (e.g. 401 responses) or users with no recorded votes must be handled gracefully without throwing errors or breaking page rendering.
- **Proposal Voting Card States**:
- If the user voted `VoteChoice.YES` (`yes` / `Positive`), the "Approve" button and icon must display active emerald styling.
- If the user voted `VoteChoice.NO` (`no` / `Negative`), the "Reject" button and icon must display active rose styling.
- If the user voted `VoteChoice.ABSTAIN` (`abstain` / `Abstain`), the "Abstain" button and icon must display active amber styling.
- If the user has not voted, all buttons retain their default neutral resting styling with hover accents.
- **Amendment Quick-Action States**:
- For each proposed amendment, if the user voted in favor (`inFavor === true`), the `Confirm` button/icon is colored emerald.
- If the user voted against (`inFavor === false`), the `Cancel` button/icon is colored rose.
- If unvoted, both icons retain neutral slate styling (`text-slate-400`).
- **Reactive Synchronization**:
- Casting a vote on either the proposal or an amendment must promptly re-fetch or optimistically update the vote state so the visual indicators reflect the change without requiring a page reload.

### Non-Functional Requirements
- **Accessibility**: Include `aria-pressed="true"` or descriptive `aria-label` / `title` attributes on active buttons to inform screen readers of the user's recorded stance.
- **Efficiency**: Fetch proposal and amendment votes in a single network request (`getUserVotes`) to avoid N+1 queries.

# Technical Design

### Current Implementation
- `ProposalService.getUserVotes(proposalId: number): CancelablePromise<UserProposalVotes>` is available in `src/generated/typescript/services/ProposalService.ts` and returns:
- `proposalVote?: ProposalVote | null` containing `choice: VoteChoice` (`'yes' | 'no' | 'abstain'`).
- `amendmentVotes: Array<UserAmendmentVote>` containing `amendmentId: number` and `vote?: AmendmentVote | null` with `inFavor: boolean`.
- `src/views/ProposalView.vue` loads `proposal` and passes it to `src/components/ProposalDetail.vue`.
- `ProposalDetail.vue` renders:
- `<ProposalVotingCard :proposal-id="proposal.id" @vote="recordVote" />`
- `<AmendmentCarousel :proposal-id="proposal.id" />`
- Currently, neither component fetches `getUserVotes` or accepts user vote props; buttons in `ProposalVotingCard.vue` and `AmendmentCarousel.vue` display static unselected styling regardless of user voting history.

### Key Decisions
1. **Centralized Vote State in `ProposalDetail.vue`**:
- *Decision*: Fetch `getUserVotes` in `ProposalDetail.vue` and pass relevant slice data as props down to `ProposalVotingCard.vue` and `AmendmentCarousel.vue`.
- *Rationale*: Prevents duplicate API requests while ensuring both components remain pure presentational/reactive consumers synchronized to the single source of truth.
2. **Resilient Unauthenticated Handling**:
- *Decision*: Wrap `ProposalService.getUserVotes` in a try/catch block; if 401 or network error occurs, silently default `userVotes` to `null`.
- *Rationale*: Allows guest users or users without wallet connection to browse proposals seamlessly without console spam or UI disruption.
3. **Semantic Tailwind Color Tokens**:
- *Decision*: Use Tailwind `emerald` (positive/approve), `rose` (negative/reject), and `amber` (abstain) classes with subtle background and border tints for active states.
- *Rationale*: Matches existing design tokens across `Chip.vue`, `AmendmentCarousel.vue`, and `VotingControls.vue` while delivering high-contrast clarity.

### Proposed Changes

#### 1. `src/components/ProposalDetail.vue`
- Import `UserProposalVotes`, `UserAmendmentVote`, `VoteChoice` from generated typescript models.
- Add reactive state `const userVotes = ref<UserProposalVotes | null>(null);`.
- Implement `fetchUserVotes(proposalId: number)`:
- Invoke `ProposalService.getUserVotes(proposalId)`.
- Store response in `userVotes.value`.
- On error (401/404/network), reset `userVotes.value = null`.
- Call `fetchUserVotes` in `onMounted` and within the `watch(() => props.proposal?.id)` handler.
- Pass `current-vote="userVotes?.proposalVote?.choice"` to `<ProposalVotingCard>`.
- Pass `:amendment-votes="userVotes?.amendmentVotes"` to `<AmendmentCarousel>`.
- Update `recordVote` to await `voteProposal` and immediately call `fetchUserVotes(props.proposal.id)`.

#### 2. `src/components/ProposalVotingCard.vue`
- Accept prop `currentVote?: VoteChoice | 'yes' | 'no' | 'abstain' | null`.
- Compute active helper booleans:
- `isApproved = computed(() => props.currentVote === VoteChoice.YES || props.currentVote === 'yes')`
- `isRejected = computed(() => props.currentVote === VoteChoice.NO || props.currentVote === 'no')`
- `isAbstained = computed(() => props.currentVote === VoteChoice.ABSTAIN || props.currentVote === 'abstain')`
- Apply conditional classes:
- Approve button: `:class="isApproved ? 'border-emerald-600 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-500' : 'border-slate-300 bg-white text-slate-700 hover:border-green-600 hover:bg-green-600 hover:text-white'"`
- Reject button: `:class="isRejected ? 'border-rose-600 bg-rose-50 text-rose-700 ring-1 ring-rose-500' : 'border-slate-300 bg-white text-slate-700 hover:border-red-600 hover:bg-red-600 hover:text-white'"`
- Abstain button: `:class="isAbstained ? 'border-amber-500 bg-amber-50 text-amber-700 ring-1 ring-amber-500' : 'border-slate-300 bg-white text-slate-700 hover:border-amber-500 hover:bg-amber-500 hover:text-slate-950'"`
- Pass `aria-pressed` based on active vote status.

#### 3. `src/components/AmendmentCarousel.vue`
- Add prop `amendmentVotes?: UserAmendmentVote[]`.
- Create a computed lookup map or helper function `getAmendmentVote(amendmentId: number): boolean | null | undefined`:
- Returns `true` if `vote?.inFavor === true`, `false` if `vote?.inFavor === false`, or `null`/`undefined` if unvoted.
- In the template for proposed amendments:
- Highlight Confirm button with `text-emerald-600 bg-emerald-50 ring-1 ring-emerald-400` when `getAmendmentVote(amendment.id) === true`.
- Highlight Cancel button with `text-rose-600 bg-rose-50 ring-1 ring-rose-400` when `getAmendmentVote(amendment.id) === false`.
- Emit a `voted` event when `confirm` or `reject` completes so parent `ProposalDetail` can refresh `getUserVotes`.

### Architecture Diagram

```mermaid
graph TD
PV[ProposalView.vue] --> PD[ProposalDetail.vue]
PD -->|1. getUserVotes proposalId| PS[ProposalService API]
PS -->|UserProposalVotes| PD
PD -->|currentVote| PVC[ProposalVotingCard.vue]
PD -->|amendmentVotes| AC[AmendmentCarousel.vue]
PVC -->|@vote -> voteProposal + refresh| PD
AC -->|@voted -> voteAmendment + refresh| PD
```

### File Structure
- `src/components/ProposalDetail.vue` *(Modified)*: Adds user votes fetching, state management, and props dispatch.
- `src/components/ProposalVotingCard.vue` *(Modified)*: Receives active proposal vote prop and renders highlight styles for Approve, Reject, and Abstain.
- `src/components/AmendmentCarousel.vue` *(Modified)*: Receives amendment votes prop, applies active color styles on Confirm/Cancel icons, and notifies parent on vote.

### Risks & Mitigations
- **Unauthenticated / Expired Tokens**: `getUserVotes` will return 401 when tokens are missing or invalid. *Mitigation*: Catch 401 errors gracefully and treat user votes as empty without throwing alerts.
- **Race Condition on Voting**: Multiple quick clicks on vote actions. *Mitigation*: Await API resolution and update state reactivity to ensure visual indicators match persisted server state.

# Testing

### Validation Approach
Verify the functionality by loading proposals in authenticated and unauthenticated states, testing all vote positions on both proposal and amendment components, and confirming real-time UI updates upon casting votes.

### Key Scenarios
1. **Proposal Vote Highlighting**:
- Navigate to a proposal where the user previously voted "Approve" -> Verify the Approve button in `ProposalVotingCard` is highlighted in emerald with active border/background.
- Navigate to a proposal where the user previously voted "Reject" -> Verify the Reject button is highlighted in rose.
- Navigate to a proposal where the user previously voted "Abstain" -> Verify the Abstain button is highlighted in amber.
- Navigate to an unvoted proposal -> Verify all 3 buttons are rendered in their neutral resting state.
2. **Amendment Vote Highlighting**:
- For an amendment where the user voted in favor (`inFavor: true`) -> Verify the checkmark (`Confirm`) icon is highlighted in emerald.
- For an amendment where the user voted against (`inFavor: false`) -> Verify the cross (`Cancel`) icon is highlighted in rose.
- For unvoted amendments -> Verify both icons render in neutral slate color (`text-slate-400`).
3. **Dynamic Voting State Updates**:
- Click "Approve" on an unvoted proposal -> Verify the Approve button immediately shifts to active highlighted state upon API confirmation.
- Click "Confirm" on a proposed amendment -> Verify the checkmark icon immediately transitions to the active emerald state.
4. **Guest / Unauthenticated Experience**:
- View proposals with no token in `localStorage` -> Verify the proposal details, comments, and amendment carousel render properly with default unselected action buttons and no console runtime errors.

### Edge Cases
- **No Amendments on Proposal**: Verify `amendmentVotes` handles empty arrays safely without runtime errors.
- **Token Expiry**: When token expires mid-session, API failures are caught cleanly, leaving icons in fallback neutral states.

# Delivery Steps

### ✓ Step 1: Integrate user vote retrieval and state management in ProposalDetail
ProposalDetail fetches and manages user vote records for the active proposal and its amendments, maintaining synchronized reactive state.

- Implement `fetchUserVotes` in `src/components/ProposalDetail.vue` using `ProposalService.getUserVotes(props.proposal.id)`.
- Gracefully handle unauthorized (401), not found (404), or network error states when a user is unauthenticated or has no recorded votes.
- Add reactive state `userVotes` typed as `UserProposalVotes | null` to track active proposal and amendment votes.
- Trigger `fetchUserVotes` on component mount and watch `proposal.id` changes to keep user vote state updated when navigating proposals.
- Pass the proposal vote selection (`userVotes?.proposalVote?.choice`) to `ProposalVotingCard` and the amendment vote mapping to `AmendmentCarousel`.

### ✓ Step 2: Render active vote indicators in ProposalVotingCard
ProposalVotingCard visually indicates the user's previously cast vote with distinct color and container styling for Approve, Reject, and Abstain options.

- Add a `currentVote` prop to `src/components/ProposalVotingCard.vue` accepting `VoteChoice | 'Positive' | 'Negative' | 'Abstain' | null | undefined`.
- Update button styles to reflect active selection states:
- **Approve**: Highlight with emerald border/background (`border-emerald-500 bg-emerald-50 text-emerald-700`) and emerald `Confirm` icon.
- **Reject**: Highlight with rose border/background (`border-rose-500 bg-rose-50 text-rose-700`) and rose `Cancel` icon.
- **Abstain**: Highlight with amber border/background (`border-amber-500 bg-amber-50 text-amber-700`) and amber `Abstain` icon.
- Add `aria-pressed` attributes to buttons for assistive technology feedback.
- Update `recordVote` in `ProposalDetail.vue` to refresh or optimistically update user vote state immediately after casting a vote.

### ✓ Step 3: Highlight previous votes in AmendmentCarousel and synchronize state
AmendmentCarousel displays colored approval and disapproval quick-action icons corresponding to the user's recorded amendment votes.

- Update `src/components/AmendmentCarousel.vue` to accept amendment vote data (e.g. `userVotes?: UserAmendmentVote[]` or a vote lookup map) from `ProposalDetail.vue`.
- Derive helper methods/computed maps to resolve a user's vote (`inFavor: true`, `inFavor: false`, or unvoted) for each amendment ID.
- Update the quick-action button styles in proposed amendment cards:
- **Approve (in favor)**: Render active emerald styling (`text-emerald-600 bg-emerald-50 border border-emerald-200`) on the `Confirm` icon button.
- **Reject (against)**: Render active rose styling (`text-rose-600 bg-rose-50 border border-rose-200`) on the `Cancel` icon button.
- **Unvoted**: Maintain neutral resting styling (`text-slate-400 hover:text-emerald-500` / `hover:text-rose-500`).
- Update `confirm` and `reject` voting handlers in `AmendmentCarousel.vue` to emit vote events or trigger vote refreshes to ensure immediate UI synchronization.

+ 154
- 0
.junie/plans/linkable-proposals-router-view.md Bestand weergeven

@@ -0,0 +1,154 @@
---
sessionId: session-261002-154517-19l9
---

# Requirements

### Overview & Goals
Enhance user utility and application navigation by making individual proposals linkable and bookmarkable via browser URLs. Replacing in-memory state selection with URL-driven routing maximizes efficiency, enables direct sharing among users, and preserves standard browser history navigation (back/forward).

### Scope
- **In Scope:**
- Transitioning router history from `createMemoryHistory()` to `createWebHistory()`.
- Configuring nested routes under `HomeView` for the empty state (`/`) and specific proposals (`/proposals/:id`).
- Replacing the static conditional component rendering in `HomeView.vue` with `<RouterView />`.
- Creating a dedicated route view (`ProposalView.vue`) to fetch and render proposal details based on route parameters.
- Updating `Sidebar.vue` and `ProposalContainer.vue` to trigger router navigation and synchronize active item highlights with the active route.
- Ensuring smooth mobile UX with automatic drawer dismissal upon navigation.
- **Out of Scope:**
- Changes to backend APIs or data models.
- Authentication guard modifications.
- Modifications to proposal editing or amendment creation workflows beyond preserving their existing modal behavior.

### User Stories
- **As a user**, I want to share a direct URL to a specific proposal so that collaborators can immediately view the proposal details without manually finding it in the sidebar.
- **As a user**, I want browser back and forward buttons to navigate between previously viewed proposals without page reloads.
- **As a user**, I want refreshing the browser on a proposal page to reload the exact same proposal seamlessly.

### Functional Requirements
- Navigating to `/` displays the proposal sidebar alongside `ProposalEmptyState` in the main view area.
- Navigating to `/proposals/:id` loads and displays the details for the proposal with ID `:id`.
- Clicking a proposal in `Sidebar.vue` navigates to `/proposals/:id`, highlights the selected proposal in the sidebar, and closes the mobile sidebar drawer if open.
- Invalid or non-existent proposal IDs show an appropriate empty or not-found state without breaking the application shell.
- Direct URL access to `/proposals/:id` highlights the respective item in the sidebar once the proposal list is loaded.

### Non-Functional Requirements
- **Performance:** Avoid unnecessary re-fetching of the full proposal list when switching between proposal detail routes.
- **Maintainability:** Clear separation between route-level container components and presentational components (`ProposalDetail.vue`, `ProposalEmptyState.vue`).
- **User Experience:** Instant visual feedback using existing loading indicators during route transitions.

# Technical Design

### Current Implementation
- `src/router/index.ts` uses `createMemoryHistory()` and only registers a single route `{ path: '/', component: HomeView }`.
- `src/views/HomeView.vue` manages the `selected` proposal state locally in component memory (`ref<ProposalWithParagraphs>()`). Clicking a sidebar item triggers `handleSelection` which directly calls `ProposalService.getProposal(id)` and sets local state.
- `src/components/Sidebar.vue` tracks selection via an internal `selectedId` ref and emits an event upward.
- `src/App.vue` renders the top-level `<RouterView />`, which mounts `HomeView.vue`.

### Key Decisions
- **Nested Routing in `HomeView.vue`:**
- *Decision:* Nest child routes inside `HomeView` rather than creating separate top-level pages.
- *Rationale:* Preserves the persistent sidebar layout and list state across navigation without re-fetching proposals on every route change, maximizing rendering efficiency and minimizing layout shifts.
- **History Mode:**
- *Decision:* Switch from `createMemoryHistory()` to `createWebHistory()`.
- *Rationale:* Enables standard URL paths (`/proposals/:id`) that work with browser history, bookmarks, and link sharing.
- **Route Component Structure:**
- *Decision:* Introduce `src/views/ProposalView.vue` as the route-level child component that reads route params, fetches data via `ProposalService`, and passes data to the existing `ProposalDetail.vue`.
- *Rationale:* Keeps `ProposalDetail.vue` purely presentational and reusable while decoupling route logic and lifecycle hooks.
- **Active State Synchronization:**
- *Decision:* Derive sidebar active state from `useRoute().params.id` instead of disconnected local component state.
- *Rationale:* Guarantees consistency across direct URL hits, back/forward browser navigation, and clicks.

### Architecture Diagram
```mermaid
graph TD
App[App.vue - Root RouterView] --> HomeView[HomeView.vue]
HomeView --> Sidebar[Sidebar.vue - List & Filters]
HomeView --> ContentRouterView[Nested RouterView]
ContentRouterView -- path: '/' --> EmptyState[ProposalEmptyState.vue]
ContentRouterView -- path: '/proposals/:id' --> ProposalView[ProposalView.vue]
ProposalView --> ProposalDetail[ProposalDetail.vue]
Sidebar -.->|router.push('/proposals/:id')| ProposalView
```

### Proposed Changes
1. **`src/router/index.ts`:**
- Change history mode to `createWebHistory()`.
- Update routes:
```ts
{
path: '/',
component: HomeView,
children: [
{ path: '', name: 'home', component: ProposalEmptyState },
{ path: 'proposals/:id', name: 'proposal', component: () => import('../views/ProposalView.vue'), props: true }
]
}
```
2. **`src/views/HomeView.vue`:**
- Replace `<ProposalDetail>`, `<ProposalEmptyState>`, and content `<LoadingSpinner>` in `<main>` with `<RouterView />`.
- Remove manual `selected` state and `handleSelection` logic since routing handles proposal loading.
- Listen to route changes to auto-close `isSidebarOpen` on mobile viewports.
3. **`src/views/ProposalView.vue` (New File):**
- Accept `id` prop (coerced to number).
- Watch `id` to invoke `ProposalService.getProposal(id)` with `useLoading('content')`.
- Render `ProposalDetail` when loaded, or appropriate error/empty fallback on failure.
4. **`src/components/Sidebar.vue` & `src/components/ProposalContainer.vue`:**
- Update `handleSelection` to navigate using `router.push({ name: 'proposal', params: { id: proposal.id } })`.
- Determine `selected` state dynamically from the active route parameter matching `proposal.id`.

### File Structure
- `src/router/index.ts` *(modified)* — Configure HTML5 web history and nested routes.
- `src/views/HomeView.vue` *(modified)* — Host nested `<RouterView />` and layout shell.
- `src/views/ProposalView.vue` *(new)* — Route view component for fetching and displaying a proposal.
- `src/components/Sidebar.vue` *(modified)* — Derive active selection from route and dispatch route navigation.

### Risks & Mitigations
- **Route Param Type Coercion:** Route params are strings by default; parsing `Number(route.params.id)` ensures compatibility with `ProposalService.getProposal(number)`.
- **Rapid Navigation Race Conditions:** Watching the proposal ID and ensuring the latest request updates state avoids stale content overwrites.

# Testing

### Validation Approach
Verification focuses on confirming that URLs accurately reflect the displayed proposal, deep links load expected data, and navigation between proposals works smoothly via both UI clicks and browser history.

### Key Scenarios
1. **Direct Deep Link Loading:**
- Navigating directly to `/proposals/1` loads proposal #1 details and highlights item #1 in the sidebar once the list is loaded.
2. **Sidebar Item Click Navigation:**
- Clicking a proposal in the sidebar navigates to `/proposals/:id`, updates the address bar URL, and displays the corresponding proposal content.
3. **Browser History Navigation:**
- Clicking back and forward buttons in the browser switches between viewed proposals and keeps sidebar selection synchronized.
4. **Empty State on Root Route:**
- Navigating to `/` renders `ProposalEmptyState` in the main content pane without errors.
5. **Mobile Drawer Behavior:**
- Selecting a proposal on mobile screens collapses the sidebar overlay and displays the selected proposal.

### Edge Cases
- **Invalid / Non-Numeric Proposal ID:** Navigating to `/proposals/abc` or `/proposals/-1` handles the error gracefully without throwing uncaught exceptions.
- **Non-Existent Proposal ID (404):** Displays a user-friendly error message or empty state when the API returns an error for a missing proposal.
- **Rapid Sequential Clicks:** Quickly clicking multiple sidebar items displays only the data for the final selected proposal.

# Delivery Steps

### ✓ Step 1: Configure Vue Router with Web History and Nested Routes
Vue Router is configured with web history and nested routes for proposal viewing.

- Replace `createMemoryHistory()` with `createWebHistory()` in `src/router/index.ts` to support browser URLs, bookmarks, and standard history navigation.
- Define child routes under `HomeView`: an empty child path `''` mapped to `ProposalEmptyState.vue` and a dynamic child path `'proposals/:id'` mapped to a proposal detail route view.
- Enable `props: true` (or custom prop mapping) on the dynamic route to pass the proposal `id` cleanly as a typed number prop.

### ✓ Step 2: Implement Proposal Route View and Data Fetching
Dedicated route view created to load and display individual proposals based on URL parameters.

- Create `src/views/ProposalView.vue` (or `ProposalDetailView.vue`) to encapsulate data retrieval for the selected proposal ID.
- Integrate `ProposalService.getProposal(id)` with reactive route parameter watchers so switching proposals updates content without remounting overhead.
- Coordinate loading states with `useLoading('content')` and render `ProposalDetail` or handle not-found/error states gracefully.

### ✓ Step 3: Update HomeView and Sidebar for Route-Driven Navigation
HomeView and Sidebar leverage Vue Router navigation and automatic route-based active selection.

- Update `src/views/HomeView.vue` main area to render `<RouterView />` instead of manual conditional rendering.
- Wire `src/components/Sidebar.vue` and `src/components/ProposalContainer.vue` to navigate to `/proposals/:id` upon proposal click.
- Sync active proposal highlighting in `Sidebar.vue` with current `route.params.id` to ensure correct state on direct URL loads, back/forward navigation, and clicks.
- Automatically dismiss the mobile sidebar overlay upon route navigation on small viewports.

+ 278
- 0
.junie/plans/modernize-icon-system.md Bestand weergeven

@@ -0,0 +1,278 @@
---
sessionId: session-261002-165538-pjxq
---

# Requirements

### Overview & Goals
The objective of this initiative is to replace the fragmented, ad-hoc SVG icon implementations in `src/components/icons/` with a high-utility, predictable, and performant Vue 3 icon architecture.

Currently, icons in the project are implemented with substantial inconsistencies:
- Several icons wrap raw SVG markup inside container `<div>` tags with hardcoded layout properties (e.g., `class="h-4 w-4 cursor-pointer mx-2"`).
- Other icons render raw SVG tags with inconsistent prop contracts (e.g., `:box="96"` vs `:viewBox="36"` vs `:width="20"` vs hardcoded inline styles).
- Fills, strokes, and hover behaviors are frequently hardcoded in scoped CSS or inline SVG attributes, preventing utility classes (Tailwind CSS `text-*`, `fill-current`, `stroke-current`, `size-*`) from controlling colors and dimensions naturally.

This proposal establishes a standardized, tree-shakeable, and type-safe icon system designed to maximize developer throughput, eliminate styling friction, reduce bundle waste, and ensure complete accessibility compliance.

### Scope

#### In Scope
- **Foundation Layer**: Create an extensible, typed `IconBase.vue` component and `types.ts` defining standard icon contracts (`IconName`, `IconSize`, sizing scales, accessibility attributes).
- **Icon Normalization**: Refactor all 14 existing icon components (`Abstain`, `Arrow`, `Cancel`, `Confirm`, `DownArrow`, `Download`, `Filter`, `Identification`, `Menu`, `Plus`, `SpeechBubbles`, `Stack`, `ThumbsDown`, `ThumbsUp`) into pure SVG components using `currentColor` and consistent viewBox geometries.
- **Dynamic Registry**: Create a unified `AppIcon.vue` for dynamic runtime icon rendering and `src/components/icons/index.ts` for clean tree-shakeable named exports.
- **Call-Site Migration**: Refactor all 8 consuming components and views (`Sidebar.vue`, `ProposalContainer.vue`, `ProposalDetail.vue`, `ProposalExportCard.vue`, `ProposalVotingCard.vue`, `VotingControls.vue`, `AmendmentCarousel.vue`, `HomeView.vue`) to use the new icon interfaces and standard Tailwind classes.
- **Build & Quality Assurance**: Ensure zero TypeScript errors (`vue-tsc`), clean Vite builds, and consistent rendering across light/dark backgrounds and responsive viewport sizes.

#### Out of Scope
- Introducing external heavyweight icon packages or third-party web font libraries that would increase bundle size or external network dependencies.
- Modifying non-icon business logic, wallet integrations, or backend API service definitions.

### User Stories
- **As a Developer**, I want all icons to behave as first-class Vue components with uniform props and `currentColor` support so that I can style their size, color, and hover states directly using Tailwind CSS without fighting inner scoped CSS or arbitrary wrapper `<div>`s.
- **As a Developer**, I want autocompletion and type checking when specifying icon names so that runtime icon typos and missing assets are caught at compile time.
- **As an End User**, I want all icons to render crisply, scale smoothly across devices, and support screen readers properly so that UI interactions are fast, intuitive, and accessible.

### Functional Requirements
- **FR-1: Standardized Root Element**: Every individual icon component must render a semantic `<svg>` root element without superfluous wrapper `<div>` elements.
- **FR-2: Sizing Flexibility**: Icons must accept standardized size presets (`'xs' | 'sm' | 'md' | 'lg' | 'xl'`), numerical/pixel dimensions, or inherit sizing directly from standard Tailwind classes (`size-4`, `w-5 h-5`).
- **FR-3: Color & Theme Inheritance**: Icons must inherit foreground colors from their parent container by default via `fill="currentColor"` or `stroke="currentColor"`, allowing instant theme adaptations (`text-slate-500`, `hover:text-green-500`, `fill-amber-500`).
- **FR-4: Dynamic & Direct Imports**: The system must support direct named component imports (e.g., `import { IconFilter } from '@/components/icons'`) and dynamic component resolution (e.g., `<AppIcon name="filter" />`).
- **FR-5: Accessibility Support**: Decorative icons must default to `aria-hidden="true"`, while standalone interactive or informational icons must support customizable `title` and `role="img"` attributes.

### Non-Functional Requirements
- **Performance**: Zero runtime overhead from external bundle bloat; SVG trees must remain tree-shakeable and lightweight.
- **Type Safety**: Fully typed with TypeScript 5+ and Vue 3 `<script setup>` compiler macros.
- **Maintainability**: Clear separation of concern between icon vector geometries and presentation styles. Adding a new icon must require minimal boilerplate.

# Technical Design

### Current Implementation
The current codebase contains 14 icon files located in `src/components/icons/`:
- `DownArrow.vue`, `Filter.vue`, `Identification.vue`, `Menu.vue`, `Plus.vue`, and `SpeechBubbles.vue` contain outer `<div class="h-4 w-4 cursor-pointer mx-2">` wrappers, hardcoding margins, cursor behavior, and dimensions inside the icon definition.
- `Abstain.vue`, `Cancel.vue`, and `Confirm.vue` define custom `withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>())` props but hardcode geometry coordinates.
- `Stack.vue` defines `:viewBox="0 0 ${viewBox} ${viewBox}"` (defaulting to 90) but hardcodes `style="width: 20px; height: 20px;"` and `fill="white"`.
- `Arrow.vue` hardcodes `style="width: 10px; height: 20px;"`.
- `SpeechBubbles.vue` and `DownArrow.vue` include scoped CSS with ID selectors and `!important` color overrides (`#down-arrow:hover`, `#speech-bubbles:hover`), preventing Tailwind styling from parent components.
- Consuming components (`VotingControls.vue`, `ProposalVotingCard.vue`, `ProposalExportCard.vue`, `AmendmentCarousel.vue`, `Sidebar.vue`, `ProposalContainer.vue`, `ProposalDetail.vue`, `HomeView.vue`) use incompatible styling workarounds (e.g., manual `:box="96"`, inline style overrides, or duplicate Tailwind wrapper classes).

### Key Decisions
1. **First-Class Vue SVG Components with `IconBase` Wrapper**:
- *Decision*: Standardize each icon as a dedicated Vue component composed around a lightweight `IconBase.vue` (or direct standardized `<svg>` element) using `currentColor`.
- *Rationale*: Eliminates unwanted DOM nesting (`<div>` wrapping `<svg>`), allows full Tailwind utility class passthrough (`class="..."` directly targets `<svg>`), and ensures maximum rendering performance and tree-shakability without external package bloat.
2. **Unified `AppIcon.vue` Dynamic Component**:
- *Decision*: Provide an `AppIcon.vue` component that maps an `IconName` string union to the corresponding icon component.
- *Rationale*: Gives developers the choice between static named component imports (best for explicit, statically analyzable components) and dynamic string-based icon rendering (best for config-driven UI, tables, or iteration).
3. **Tailwind-First Sizing & Color Design**:
- *Decision*: Default icon dimensions to `1em` (or preset sizes) and `currentColor` fill/stroke, allowing standard Tailwind classes (`size-4`, `text-slate-400`, `hover:text-amber-500`) to govern appearance effortlessly.
- *Rationale*: Maximizes developer utility by keeping icon styling consistent with all other UI components in Tailwind CSS.

### Proposed Architecture & Flow

```mermaid
graph TD
subgraph Consumers
A[Sidebar.vue]
B[VotingControls.vue]
C[ProposalVotingCard.vue]
D[AmendmentCarousel.vue]
E[ProposalContainer.vue]
F[Other Views / Modals]
end

subgraph Icon System Layer
G[AppIcon.vue Dynamic Component]
H[Named Exports in icons/index.ts]
I[IconBase.vue Core Sizing & A11y Wrapper]
end

subgraph Icon Definitions
J[IconAbstain.vue]
K[IconConfirm.vue]
L[IconCancel.vue]
M[IconFilter.vue]
N[IconDownload.vue]
O[IconIdentification.vue]
P[Other 8 SVG Icons...]
end

A & B & C & D & E & F -->|Static Named Import| H
A & B & C & D & E & F -->|Dynamic Name Prop| G
G --> H
H --> J & K & L & M & N & O & P
J & K & L & M & N & O & P -->|Composes / Inherits| I
```

### Data Models / Contracts

#### `src/components/icons/types.ts`
```typescript
export type IconName =
| 'abstain'
| 'arrow'
| 'cancel'
| 'confirm'
| 'down-arrow'
| 'download'
| 'filter'
| 'identification'
| 'menu'
| 'plus'
| 'speech-bubbles'
| 'stack'
| 'thumbs-down'
| 'thumbs-up';

export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number | string;

export interface IconBaseProps {
size?: IconSize;
viewBox?: string;
title?: string;
ariaHidden?: boolean;
role?: string;
}
```

#### `src/components/icons/IconBase.vue`
```vue
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:viewBox="viewBox"
:width="computedSize"
:height="computedSize"
:aria-hidden="title ? undefined : ariaHidden"
:aria-label="title"
:role="title ? 'img' : (role || 'presentation')"
class="inline-block shrink-0 align-middle select-none fill-current"
>
<title v-if="title">{{ title }}</title>
<slot />
</svg>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { IconBaseProps } from './types';

const props = withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 24 24',
ariaHidden: true,
size: 'md',
});

const sizeMap: Record<string, string> = {
xs: '12',
sm: '16',
md: '20',
lg: '24',
xl: '32',
};

const computedSize = computed(() => {
if (typeof props.size === 'number') return props.size;
return sizeMap[props.size] || props.size;
});
</script>
```

### File Structure & Changes

```
src/
├── components/
│ ├── icons/
│ │ ├── types.ts # [NEW] IconName, IconSize, Prop interfaces
│ │ ├── IconBase.vue # [NEW] Common SVG wrapper component
│ │ ├── AppIcon.vue # [NEW] Dynamic icon component resolver
│ │ ├── index.ts # [NEW] Centralized export entry point
│ │ ├── Abstain.vue # [REFACTORED] Standardized SVG component
│ │ ├── Arrow.vue # [REFACTORED] Standardized SVG component
│ │ ├── Cancel.vue # [REFACTORED] Standardized SVG component
│ │ ├── Confirm.vue # [REFACTORED] Standardized SVG component
│ │ ├── DownArrow.vue # [REFACTORED] Removed wrapper div & CSS overrides
│ │ ├── Download.vue # [REFACTORED] Standardized SVG component
│ │ ├── Filter.vue # [REFACTORED] Removed wrapper div & CSS overrides
│ │ ├── Identification.vue # [REFACTORED] Removed wrapper div & standardized viewBox
│ │ ├── Menu.vue # [REFACTORED] Removed wrapper div & standardized stroke/fill
│ │ ├── Plus.vue # [REFACTORED] Removed wrapper div
│ │ ├── SpeechBubbles.vue # [REFACTORED] Removed wrapper div & CSS overrides
│ │ ├── Stack.vue # [REFACTORED] Standardized viewBox & removed hardcoded fill
│ │ ├── ThumbsDown.vue # [REFACTORED] Standardized SVG component
│ │ └── ThumbsUp.vue # [REFACTORED] Standardized SVG component
│ ├── AmendmentCarousel.vue # [UPDATED] Clean icon props & Tailwind classes
│ ├── ProposalContainer.vue # [UPDATED] Clean icon sizing without wrapper conflicts
│ ├── ProposalDetail.vue # [UPDATED] Clean icon sizing without wrapper conflicts
│ ├── ProposalExportCard.vue # [UPDATED] Clean icon props & transitions
│ ├── ProposalVotingCard.vue # [UPDATED] Removed obsolete :box props; standardized colors
│ ├── Sidebar.vue # [UPDATED] Replaced wrapped Filter with clean SVG icon
│ └── VotingControls.vue # [UPDATED] Clean Tailwind fill & hover state integration
└── views/
└── HomeView.vue # [UPDATED] Clean Stack icon styling
```

### Risks & Mitigations
- **Risk**: Visual regression if existing viewBox ratios or path alignments change during normalization.
- *Mitigation*: Preserve the exact vector path strings and original coordinate spaces (`viewBox="0 0 30 30"`, `0 0 96 96"`, etc.) for each respective icon inside its component definition, ensuring 100% pixel-perfect output.
- **Risk**: Missing layout spacing when removing wrapper divs (e.g. `mx-2`, `cursor-pointer`).
- *Mitigation*: Move layout concerns (`mx-2`, `cursor-pointer`, flex alignments) directly to the consuming container or parent button elements at all call sites.

# Testing

### Validation Approach
Verification will follow a multi-tier approach to ensure structural, aesthetic, and functional correctness across the application:
1. **Static Type & Lint Validation**: Verify strict type conformity via TypeScript compiler (`vue-tsc -b`) to ensure no broken imports or invalid prop bindings exist.
2. **Component Integration & Build Validation**: Perform full production bundling (`vite build`) to confirm zero asset resolution issues or tree-shaking failures.
3. **Visual & Interaction Verification**: Validate icon rendering across all consuming views, checking sizing, color inheritance, hover states, and responsive behavior.

### Key Scenarios
- **Scenario 1: Dynamic Icon Rendering (`AppIcon`)**:
- Render `<AppIcon name="confirm" class="text-green-500" />` and `<AppIcon name="cancel" class="text-red-500" />`.
- Expected: Correct SVGs render with specified colors, inheriting parent classes properly without extra container nodes.
- **Scenario 2: Direct Component Import & Tailwind Utility Sizing**:
- Render `<IconFilter class="size-4 text-white hover:text-slate-200" />` in `Sidebar.vue`.
- Expected: Icon scales to 16x16px, displays in white, and transitions to light slate on hover without unwanted wrapper margins.
- **Scenario 3: Interactive Voting Controls & Active States**:
- In `VotingControls.vue` and `ProposalVotingCard.vue`, trigger approval and rejection vote states.
- Expected: `Confirm` and `Cancel` icons respond dynamically to positive/negative vote states (`fill-green-500`, `fill-red-500`) with smooth transitions.
- **Scenario 4: Carousel Navigation Chevrons**:
- In `AmendmentCarousel.vue`, navigate through amendments using the left and right arrow buttons.
- Expected: `Arrow` icon renders crisply, rotates correctly with Tailwind's `rotate-180` for left navigation, and responds to click events.

### Edge Cases
- **Custom Dimension Overrides**: Passing explicit numeric `size={28}` or arbitrary Tailwind strings (`class="w-7 h-7"`) must scale SVG viewports smoothly without clipping.
- **Accessibility Verification**: When `title` is supplied, `<svg>` must render a `<title>` node and set `role="img"`; when omitted, it must set `aria-hidden="true"`.
- **Empty or Missing Styling**: Icons with no explicit color class must default to `currentColor`, seamlessly adopting surrounding text color.

# Delivery Steps

### ✓ Step 1: Establish IconBase and type system
A unified `IconBase.vue` component and type definitions are created to serve as the standardized foundation for all project icons.

- Define TypeScript contracts and type definitions in `src/components/icons/types.ts` (`IconName`, `IconSize`, `IconBaseProps`).
- Create `src/components/icons/IconBase.vue` rendering a semantic `<svg>` root element with default classes (`inline-block shrink-0 align-middle select-none`), standardized sizing mapping (`xs`: 12px, `sm`: 16px, `md`: 20px, `lg`: 24px, `xl`: 32px or custom dimensions), `currentColor` inheritance, and accessibility attributes (`aria-hidden`, `role="img"`, `title`).
- Create `src/components/icons/AppIcon.vue` providing dynamic lookup and rendering of icons by name with full type safety and prop forwarding.

### ✓ Step 2: Refactor and normalize all icon components
All 14 icon components are refactored into clean, single-element SVG components adhering to the standardized base interface.

- Remove extraneous `<div>` wrapper containers and hardcoded margins (`mx-2`) from `DownArrow.vue`, `Filter.vue`, `Identification.vue`, `Menu.vue`, `Plus.vue`, and `SpeechBubbles.vue`.
- Eliminate hardcoded inline styles (`style="fill:#ffffff"`, `style="width: 10px; height: 20px;"`) and scoped hover overrides (`#speech-bubbles:hover`, `#down-arrow:hover`) in favor of standard `currentColor` / Tailwind class pass-through.
- Standardize viewBox definitions and geometry across `Abstain.vue`, `Arrow.vue`, `Cancel.vue`, `Confirm.vue`, `Download.vue`, `Stack.vue`, `ThumbsDown.vue`, `ThumbsUp.vue`, and other icons.
- Create `src/components/icons/index.ts` exporting all icon components as named exports, the dynamic `AppIcon` component, and relevant TypeScript types for streamlined imports.

### ✓ Step 3: Migrate consuming components to modernized icons
All existing UI components and views are updated to consume the normalized icon system cleanly with Tailwind styling.

- Update `src/components/Sidebar.vue` to use `<Filter />` (or `<IconFilter />`) cleanly inside navigation buttons without unwanted wrapper margins.
- Update `src/components/ProposalContainer.vue` and `src/components/ProposalDetail.vue` to render `Identification` with proper dimensions and colors.
- Update `src/components/VotingControls.vue` and `src/components/ProposalVotingCard.vue` to remove obsolete `:box` / `:width` / `:height` props from `Confirm`, `Cancel`, and `Abstain`, styling them with Tailwind classes (`size-5 text-green-500 hover:text-green-600 fill-current`).
- Update `src/components/AmendmentCarousel.vue` and `src/components/ProposalExportCard.vue` to use standard arrow and download icons with seamless transition and hover effects.
- Update `src/views/HomeView.vue` to replace inline styles on `Stack` with standard component props/classes.

### ✓ Step 4: Verify build integrity, visual fidelity, and accessibility
The modernized icon system is validated across TypeScript compilation, build pipeline, and UI rendering.

- Execute `vue-tsc -b` and `vite build` to guarantee zero type errors or broken import references across the application.
- Verify visual fidelity, color transitions, hover states, and responsive layout scaling across all pages (Home, Proposal Detail, Voting modal, Carousel).
- Ensure accessibility tags (`aria-hidden="true"` on decorative icons and appropriate labels on interactive icon buttons) work correctly without DOM nesting violations.

+ 177
- 0
.junie/plans/persist-wallet-data-use-session.md Bestand weergeven

@@ -0,0 +1,177 @@
---
sessionId: session-261005-211542-1soe
---

# Requirements

### Overview & Goals
To maximize application utility and deliver an optimal user experience, users should not have to manually reconnect their Freighter wallet or re-authenticate after every browser refresh. The objective is to enhance `src/composables/useSession.ts` to manage and persist wallet connection data and authentication tokens across page reloads, making session state globally available and reactive across all UI components.

### Scope
- **In Scope**:
- Expanding `src/composables/useSession.ts` into a reactive session composable managing wallet address, user ID, auth token, and connection lifecycle.
- Hydrating wallet and authentication state from `localStorage` on initial application load and browser refresh.
- Providing centralized methods in `useSession.ts` for connecting, disconnecting, checking Freighter status, and formatting wallet displays.
- Refactoring `src/components/BrandBar.vue` and `src/App.vue` to leverage `useSession()` for unified session state management.
- Ensuring outgoing API requests via OpenAPI client continue to resolve persisted tokens seamlessly.
- **Out of Scope**:
- Modifying backend authentication endpoints or token validation mechanisms.
- Modifying Freighter browser extension APIs or Stellar SDK internal logic.

### User Stories
- As a platform user, I want my wallet connection and authentication status to persist when I refresh the page so that I do not need to repeatedly click "Connect Wallet" and approve popups.
- As a user, I want the header brand bar to immediately reflect my connected wallet address on page load so that I know my active session is valid.
- As a developer, I want a single source of truth for wallet session state in `useSession.ts` so that components across the application can reliably access authentication details without duplicate storage or Freighter queries.

### Functional Requirements
- **Automatic State Hydration**: On application mount or composable initialization, `useSession` checks `localStorage` for existing `authToken` and cached wallet address, verifies Freighter extension availability, and synchronizes reactive state.
- **Unified Connect Flow**: `connectWallet()` requests Freighter permission (`setAllowed()`), retrieves the public key (`getAddress()`), logs in via `AuthService.loginFreighter`, stores the token in `localStorage`, and updates reactive state.
- **Clean Disconnect Flow**: `disconnectWallet()` removes authentication and wallet data from `localStorage` and resets reactive variables to unauthenticated state.
- **Reactive UI Synchronization**: `BrandBar.vue` dynamically displays either "Connect Wallet" or the truncated address based on `useSession` reactive state.

### Non-Functional Requirements
- **Resilience & Fault Tolerance**: If storage is corrupted, inaccessible, or the JWT is invalid/expired, `useSession` safely resets state without throwing unhandled exceptions.
- **Performance**: Hydration must occur synchronously/microtask-level during initial render so UI does not flicker between unauthenticated and authenticated states.

# Technical Design

### Current Implementation
- `src/composables/useSession.ts` is currently a minimal stub containing only `hasFreighter = async () => freighter.isConnected()`.
- `src/components/BrandBar.vue` independently executes Freighter login, sets `localStorage.setItem("authToken", ...)`, and updates a local component ref `userText`. When the user refreshes, this component state is lost and resets to `'Connect Wallet'`.
- `src/App.vue` calls `freighter.getAddress()` in `onBeforeMount` and stores it in an isolated local ref `address`.
- `src/utils/auth.ts` provides `getAuthToken()`, `parseJwt()`, and `getUserIdFromJwt()`, retrieving tokens from `localStorage`.

### Key Decisions
- **Shared Module-Level Reactive State in `useSession.ts`**:
- *Decision*: Maintain `walletAddress`, `authToken`, `userId`, `isConnecting`, and `isInitialized` as module-level `ref`s within `useSession.ts` (consistent with patterns used in `useModal.ts` and `useLoading.ts`).
- *Rationale*: Guarantees all consumer components share the exact same reactive session state without requiring redundant Pinia store overhead or desynchronized state copies.
- **Storage Strategy**:
- *Decision*: Persist `authToken` under the standard `'authToken'` key in `localStorage` and derive/cache `stellarAddress` and `userId` directly from JWT payload parsing (`parseJwt`) and Freighter sync.
- *Rationale*: Keeps `localStorage` minimal, avoids stale divergent data, and maintains full compatibility with `src/main.ts` (`OpenAPI.TOKEN`) and `src/utils/auth.ts`.
- **Eager Hydration & Verification**:
- *Decision*: Restore state immediately from `localStorage` on initial access, followed by asynchronous verification with Freighter (`freighter.isAllowed()`).
- *Rationale*: Eliminates UI layout shifts or "flash of unauthenticated content" while still ensuring the browser extension remains connected.

### Proposed Changes
1. **`src/composables/useSession.ts`**:
- Add reactive state: `walletAddress: ref<string | null>`, `authToken: ref<string | null>`, `userId: ref<number | undefined>`, `isConnecting: ref<boolean>`.
- Add computed helpers: `isAuthenticated: computed(() => !!authToken.value && !!walletAddress.value)`, `truncatedAddress: computed(() => walletAddress.value ? truncateWallet(walletAddress.value) : '')`.
- Implement `initSession()` to restore token from `localStorage`, decode JWT for `stellarAddress` / `userId`, and verify with Freighter if available.
- Implement `connectWallet()` and `disconnectWallet()`.
- Implement `hasFreighter()` to check extension availability.
2. **`src/components/BrandBar.vue`**:
- Replace local `userText` logic with `const { isAuthenticated, truncatedAddress, isConnecting, connectWallet, disconnectWallet } = useSession()`.
- Compute button text: show `truncatedAddress` if authenticated, `"Connecting..."` if connecting, or `"Connect Wallet"`.
3. **`src/App.vue`**:
- Initialize session via `initSession()` in `onBeforeMount` / `onMounted`.
- Remove isolated, unused `address` ref.

### Data Models / Contracts
```typescript
export interface SessionState {
walletAddress: string | null;
authToken: string | null;
userId: number | undefined;
isAuthenticated: boolean;
isConnecting: boolean;
hasFreighter: () => Promise<boolean>;
connectWallet: () => Promise<boolean>;
disconnectWallet: () => void;
initSession: () => Promise<void>;
truncatedAddress: string;
}
```

### Components
- `src/composables/useSession.ts`: Core session state, persistence, and wallet connection management.
- `src/components/BrandBar.vue`: Primary consumer for wallet connect button and truncated address display.
- `src/App.vue`: Top-level application shell invoking session initialization during application bootstrap.
- `src/utils/auth.ts`: Helper utilities utilized by `useSession.ts` for token resolution and JWT decoding.

### File Structure
- `src/composables/useSession.ts` (Modified: add full reactive session management and local persistence)
- `src/components/BrandBar.vue` (Modified: wire to `useSession`)
- `src/App.vue` (Modified: initialize session on mount and clean up duplicate state)

### Architecture Diagram
```mermaid
graph LR
subgraph UI Layer
BB[BrandBar.vue]
APP[App.vue]
MOD[Proposal/Amendment Modals]
end

subgraph State & Session
US[useSession.ts Composable]
AUTH[src/utils/auth.ts]
end

subgraph External & Storage
LS[(localStorage)]
FR[Freighter Wallet Extension]
API[AuthService / Backend]
end

APP -->|initSession| US
BB -->|connect / display| US
MOD -->|read wallet / auth| US
US -->|read / write token| LS
US -->|parse JWT| AUTH
US -->|getAddress / setAllowed| FR
US -->|loginFreighter| API
```

### Risks
- **Freighter Extension Locked / Permission Denied**: If a user locks their Freighter wallet or declines permission, `useSession` will catch the error, log a warning, and keep or revert the state cleanly without crashing the UI.
- **Corrupted Token in Storage**: If `localStorage` contains an invalid JWT string, `parseJwt()` returns `null`, prompting `useSession` to clear the bad token and reset state.

# Testing

### Validation Approach
Verify that the user's wallet connection and JWT authentication remain intact and reactive across page refreshes, and that connection/disconnection lifecycles function properly without regressions in API request authorization.

### Key Scenarios
1. **Wallet Connection & Persistence Across Page Reload**:
- Click "Connect Wallet" in `BrandBar.vue` and approve connection.
- Verify that the button updates to the truncated wallet address and `authToken` is saved in `localStorage`.
- Refresh the browser (F5 / full reload).
- Verify that on page load, `BrandBar.vue` immediately displays the truncated wallet address without requiring the user to click "Connect Wallet" again.
2. **Authenticated API Calls After Reload**:
- With an existing persisted session, refresh the browser and navigate to proposal details or submit a comment/amendment.
- Verify that outgoing requests include the `Authorization: Bearer <token>` header without authentication failure.
3. **Session Disconnection / Logout**:
- Trigger disconnect (or simulate logout).
- Verify that `localStorage` items are cleared, reactive state resets, and `BrandBar.vue` updates to "Connect Wallet".
- Refresh the page and confirm the unauthenticated state remains.

### Edge Cases
- **Page Load with No Token**: Initial load with empty `localStorage` renders "Connect Wallet" cleanly with no errors.
- **Freighter Extension Not Installed**: `hasFreighter()` returns `false` and clicking connect displays a descriptive message rather than throwing unhandled rejections.
- **Expired / Malformed Token**: Corrupted token in `localStorage` is safely purged during hydration.

# Delivery Steps

### ✓ Step 1: Implement reactive wallet session state and persistence in useSession.ts
`useSession.ts` provides a centralized reactive store for wallet address, authentication token, user ID, and connection status, with automatic local persistence and hydration.

- Define reactive module-level state variables (`walletAddress`, `authToken`, `userId`, `isConnecting`, `isInitialized`) to maintain shared session state across all consumer components.
- Implement `initSession()` to restore saved credentials (`authToken` and `walletAddress`) from `localStorage`, validate with `parseJwt()`, and verify Freighter extension availability via `freighter.isConnected()` and `freighter.isAllowed()`.
- Implement `connectWallet()` to orchestrate permission request via `freighter.setAllowed()`, fetch public address via `freighter.getAddress()`, execute `AuthService.loginFreighter`, store `authToken` and `walletAddress` in `localStorage`, and update reactive session state.
- Implement `disconnectWallet()` to clear stored tokens and wallet data from `localStorage` and reset reactive state.
- Expose helper computed properties and functions including `isAuthenticated`, `truncatedAddress`, `hasFreighter`, and connection status flags.

### ✓ Step 2: Integrate useSession into BrandBar.vue and App.vue
`BrandBar.vue` and `App.vue` consume `useSession.ts`, automatically restoring and displaying the active wallet address upon reload without user intervention.

- Update `App.vue` on-mount lifecycle to invoke `initSession()` from `useSession`, ensuring wallet and auth state are restored immediately during startup.
- Refactor `BrandBar.vue` to bind wallet button text and connection trigger directly to `useSession`'s `isAuthenticated`, `truncatedAddress`, `isConnecting`, and `connectWallet` / `disconnectWallet`.
- Remove redundant direct Freighter API invocations in `BrandBar.vue` and `App.vue` in favor of centralized composable methods.
- Ensure smooth fallback UI handling when Freighter extension is not installed or permissions are revoked.

### ✓ Step 3: Validate wallet persistence across page refreshes and edge cases
The complete wallet persistence flow is verified across reloads, active voting/proposal actions, and logout scenarios.

- Test connecting a Freighter wallet and refreshing the browser: confirm that wallet address, truncated display text, and JWT bearer token are immediately retained.
- Verify that authenticated API requests dispatched through generated service clients (`CommentService`, `ProposalService`, `AmendmentService`) continue to attach the bearer token after page reload.
- Test edge cases such as corrupted `localStorage` tokens, expired JWTs, Freighter wallet disconnection, and missing browser extensions to ensure graceful fallback without runtime errors.

+ 110
- 0
.junie/plans/style-amendment-carousel-cards.md Bestand weergeven

@@ -0,0 +1,110 @@
---
sessionId: session-261005-163409-12mw
---

# Requirements

### Overview & Goals
Enhance the visual design, contrast hierarchy, and layout ergonomics of the amendment cards inside `AmendmentCarousel.vue`. The carousel cards will feature a distinct yet gentle contrast against the surrounding background surface, cleaner typography, well-proportioned padding, refined interactive states, and consistent styling with the rest of the application's design system.

### Scope
- **In Scope**:
- Updating the background color, borders, shadows, and hover transitions of amendment cards in `AmendmentCarousel.vue`.
- Harmonizing the visual contrast between the carousel track and card surfaces.
- Improving card internal typography (header font, summary line height, status chip positioning).
- Enhancing action buttons (confirm/reject) and bottom fade gradients.
- Modernizing the "Add Amendment" placeholder card to match dimensions and visual hierarchy.
- Refining carousel navigation arrow buttons and keyboard focus states.
- **Out of Scope**:
- Changes to underlying backend API schemas or data models.
- Functional logic changes to amendment creation or voting workflows.

### User Stories
- **As a user reading proposals**, I want amendment cards to stand out clearly from the page background with balanced contrast and legible text so that I can quickly review proposed changes without visual fatigue.
- **As a voter/reviewer**, I want interactive elements (confirm, reject, card click, add amendment) to have intuitive hover and focus feedback so that navigating and acting upon amendments is efficient and error-free.

### Functional & Visual Requirements
- **Card Contrast & Surface**: Amendment cards must have a defined surface (`bg-white` / `bg-slate-50`) paired with subtle bordering (`border border-slate-200`) and soft shadow elevation (`shadow-xs` / `shadow-sm`) to ensure clear separation from neutral carousel containers.
- **Spacing & Proportions**: Inner card padding must expand from the current tight `px-2 py-1` to a balanced `p-3.5` or `p-4`, ensuring adequate breathing room for content.
- **Typography & Details**:
- Card title formatted cleanly using header font styles with proper wrapping/truncation.
- Status chips positioned with uniform margin and alignment.
- Multi-line summary truncated with a fading gradient that dynamically matches the card surface during hover transitions.
- **Action Buttons**: Quick-action confirm and reject icons sized appropriately with accessible hover backgrounds and contrast colors.
- **"Add Amendment" Card**: Styled with dashed borders, matching border radii and heights, and harmonious amber highlight accents upon hover.
- **Navigation Controls**: Left/right carousel scroll buttons updated with modern rounded borders, backdrop blur, and distinct focus rings.

# Technical Design

### Current Implementation
In `src/components/AmendmentCarousel.vue`, the amendment card currently uses:
- `class="w-36 md:w-72 lg:w-80 shrink-0 rounded-md shadow-lg px-2 py-1 cursor-pointer hover:bg-amber-50 transition-colors"`
- Missing an explicit base background color, causing inconsistent contrast against different page container backgrounds (`bg-gray-200` in `App.vue`, `ProposalDetail.vue` sections).
- Cramped internal padding (`px-2 py-1`) and a static gradient mask (`from-white`) that can clash during transitions.
- Action buttons (`Confirm`, `Cancel`) lacking dedicated interactive hit targets.

### Key Decisions
1. **Subtle Surface Contrast vs Harsh Borders**:
- *Decision*: Use `bg-white` (or `bg-slate-50/90`) paired with a lightweight border (`border-slate-200/80` to `border-slate-300`) and gentle elevation (`shadow-sm` on rest, `shadow-md` on hover).
- *Rationale*: Maximizes readability and visual clarity while maintaining a calm, modern aesthetic that fits the application's slate/amber palette.
2. **Dynamic Gradient Masking**:
- *Decision*: Tie the summary fade gradient to the card's background state (`bg-gradient-to-t from-white group-hover:from-amber-50/70`).
- *Rationale*: Eliminates visual artifacting when hovering over cards with long text summaries.
3. **Ergonomic Sizing & Targets**:
- *Decision*: Expand default minimum card widths (`w-48 sm:w-72 lg:w-80`) and provide `p-4` internal padding, giving action buttons `p-1 rounded-full hover:bg-slate-100` hitboxes.
- *Rationale*: Improves tap target usability and visual pacing across responsive viewports.

### Proposed Changes
1. **`src/components/AmendmentCarousel.vue`**:
- Refactor amendment `<article>` card markup and classes:
- Apply `group relative flex flex-col justify-between rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900`.
- Update header section inside card:
- Title styled with `font-header font-bold text-slate-900 text-sm sm:text-base leading-snug`.
- Confirm/Reject actions styled with subtle circular icon button wrappers.
- Update summary text block and gradient overlay:
- `text-xs leading-relaxed text-slate-600` with smoothly fading overlay.
- Refactor "Add Amendment" `<article>`:
- Apply matching rounded corners (`rounded-xl`), dashed border styling (`border-2 border-dashed border-slate-300 hover:border-amber-500`), and balanced icon/label grouping.
- Modernize `.carousel-btn`:
- Update class styling to `border border-slate-300 bg-white/95 text-slate-700 shadow-md hover:bg-slate-50 hover:text-slate-950 hover:border-slate-400 backdrop-blur-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900`.

### File Structure
- `src/components/AmendmentCarousel.vue` *(Modified)*: Main carousel component containing card templates, scroller, action triggers, and scoped styles.

# Testing

### Validation Approach
Verify visual appeal, responsive layout behavior, contrast ratios, and interactive states directly in the Vue component.

### Key Scenarios
1. **Resting & Hover States**:
- Verify amendment cards render with clean background separation from the carousel track.
- Verify hover transition smoothly shifts background tint (`amber-50/40`), border accent, and elevation without layout shift.
2. **Text Hierarchy & Gradient Mask**:
- Verify amendment title, status chip, and multi-line summary render with clear typographic hierarchy.
- Verify summary text fade seamlessly matches the card surface on both resting and hover states.
3. **Interactive Actions & Buttons**:
- Confirm and reject buttons trigger appropriate handlers with clear visual feedback upon hover and click.
- Verify "Add Amendment" card matches carousel dimensions and triggers creation modal on click / keyboard navigation.
4. **Carousel Navigation & Responsiveness**:
- Verify scroll left and right buttons appear when content overflows and operate smoothly with visible focus indicators.
- Test layout across mobile (`w-48` / `w-64`) and desktop breakpoints (`w-72` / `w-80`).

# Delivery Steps

### ✓ Step 1: Refine amendment card surface styling and contrast hierarchy
Amendment cards feature subtle surface contrast against the background, refined spacing, and polished borders.

- Update the card container background to `bg-white` (or `bg-slate-50/90`) with a fine border (`border border-slate-200/80` or `border-slate-300`) and soft elevation (`shadow-xs` / `shadow-sm` transitioning to `shadow-md` on hover).
- Increase inner padding to `p-4` (or `p-3.5`) for improved breathing room and ergonomic proportions.
- Update hover state to cleanly transition surface background (`hover:bg-amber-50/70` or `hover:border-amber-300`).
- Ensure cards maintain consistent height and flex alignment across different screen sizes (`w-48 sm:w-72 lg:w-80`).

### ✓ Step 2: Harmonize card content typography, action controls, and carousel styling
Amendment content, action buttons, gradient masks, the 'Add Amendment' card, and navigation buttons look cohesive and accessible.

- Apply header typography styling (`font-header` or `font-semibold text-slate-900`) to amendment titles with proper text truncation.
- Enhance confirm/reject action buttons with dedicated hover/focus states, touch target padding, and tooltips or aria attributes.
- Align the summary text line-height and update the bottom gradient fade (`bg-gradient-to-t from-white group-hover:from-amber-50/70`) to match the card background.
- Redesign the 'Add Amendment' dashed card with matching dimensions, rounded corners, subtle slate background tint, and smooth amber hover transitions.
- Polish carousel navigation buttons with clean border, backdrop blur, and accessible focus rings.

+ 1
- 1
index.html Bestand weergeven

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Puff Pastry</title>
<title>Senara</title>
</head>
<body>
<div id="app"></div>


+ 5
- 7
src/App.vue Bestand weergeven

@@ -9,15 +9,13 @@
<script setup lang="ts">
import { useModal } from "./composables/useModal.ts";
import BrandBar from "./components/BrandBar.vue";
import {onBeforeMount, ref} from "vue";
import freighter from "@stellar/freighter-api";
import { onBeforeMount } from "vue";
import { useSession } from "./composables/useSession";

const { getModalContent, getVisibility } = useModal();

const address = ref("");
const { initSession } = useSession();

onBeforeMount(async () => {
const addr = await freighter.getAddress();
address.value = addr.address;
})
await initSession();
});
</script>

+ 227
- 83
src/components/AmendmentCarousel.vue Bestand weergeven

@@ -1,125 +1,269 @@
<template>
<div class="relative bg-white rounded-md px-1 py-2 overflow-hidden w-full whitespace-nowrap">
<div class="relative w-full">
<button
class="flex items-center justify-center absolute top-1/2 left-0 border-2 border-gray-700 -translate-y-1/2 translate-x-1/3 z-10 bg-white rounded-full shadow-md w-10 h-10 rotate-180"
@click="scrollLeft"
v-show="!scrolledNone()"
v-show="canScrollLeft"
type="button"
class="carousel-btn left-0 translate-x-1/3 rotate-180"
aria-label="Scroll amendments left"
@click="scrollBy(-1)"
>
<Arrow/>
<Arrow class="size-4" />
</button>

<div ref="tray" class="tray" :style="{transform: `translateX(-${offset}px)`}">
<div
ref="scroller"
class="flex gap-3 overflow-x-auto scroll-smooth px-1 py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
@scroll.passive="updateEdges"
>
<article
class="inline-block rounded-md shadow-lg mx-1.5 py-1 px-2 w-36 md:w-72 lg:w-80 cursor-pointer hover:bg-amber-50 transition-colors"
v-for="amendment in amendments"
@click="showAmendment(amendment.id, amendment.status)">
<div class="flex flex-row justify-between">
<div class="font-bold mb-0.5 leading-tighter">{{ amendment.name }}</div>
<div class="flex flex-row items-center" v-if="amendment.status.toLowerCase() === 'proposed'">
<Confirm class="cursor-pointer fill-[#a1a1a1] hover:fill-[#00b894]"/>
<Cancel class="cursor-pointer fill-[#a1a1a1] hover:fill-[#ff5f56]"/>
:key="amendment.id"
class="group relative flex flex-col justify-between w-48 sm:w-72 lg:w-80 shrink-0 rounded-xl border border-slate-200/90 bg-white p-4 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-300 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
@click="showAmendment(amendment.id)"
@keydown.enter.prevent="showAmendment(amendment.id)"
@keydown.space.prevent="showAmendment(amendment.id)"
>
<div class="flex items-start justify-between gap-2">
<h3 class="font-header font-bold text-slate-900 text-sm sm:text-base leading-snug line-clamp-2">{{ amendment.name }}</h3>
<div
v-if="amendment.status.toLowerCase() === 'proposed'"
class="flex items-center gap-1 shrink-0"
>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === true"
aria-label="Confirm amendment"
title="Confirm amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === true
? 'text-emerald-600 bg-emerald-50 border border-emerald-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-emerald-500'"
@click.stop="confirm(amendment.id)"
>
<Confirm class="size-4" />
</button>
<button
type="button"
:aria-pressed="getAmendmentVote(amendment.id) === false"
aria-label="Reject amendment"
title="Reject amendment"
class="rounded-full p-1 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900"
:class="getAmendmentVote(amendment.id) === false
? 'text-rose-600 bg-rose-50 border border-rose-200'
: 'text-slate-400 hover:bg-slate-100 hover:text-rose-500'"
@click.stop="reject(amendment.id)"
>
<Cancel class="size-4" />
</button>
</div>
</div>
<Chip
:text="amendment.status"
:class="getChipClass(amendment.status)"/>
<div class="text-xs whitespace-normal text-gray-600 h-12 overflow-hidden relative">
{{amendment.summary}}
<div class="absolute bottom-0 left-0 h-4 w-full from-white to-transparent bg-gradient-to-t"></div>
<div class="mt-2.5 mb-1.5 flex items-center">
<Chip :text="amendment.status" :class="getChipClass(amendment.status)" />
</div>
<p class="relative mt-1 h-12 overflow-hidden text-xs leading-relaxed text-slate-600">
{{ amendment.summary }}
<span class="pointer-events-none absolute inset-x-0 bottom-0 h-4 bg-gradient-to-t from-white group-hover:from-amber-50/40 transition-colors duration-200" />
</p>
</article>
<article
class="group flex w-48 sm:w-72 lg:w-80 shrink-0 flex-col items-center justify-center rounded-xl border-2 border-dashed border-slate-300 bg-slate-50/70 p-4 text-center shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-amber-400 hover:bg-amber-50/40 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900 cursor-pointer"
role="button"
tabindex="0"
aria-label="Add amendment"
@click="openNewAmendmentModal"
@keydown.enter.prevent="openNewAmendmentModal"
@keydown.space.prevent="openNewAmendmentModal"
>
<div class="mb-2 flex size-8 items-center justify-center rounded-full bg-slate-200 text-slate-700 transition-colors group-hover:bg-amber-400 group-hover:text-white">
<Plus class="size-4" />
</div>
<h3 class="font-header text-sm sm:text-base font-bold text-slate-800 transition-colors group-hover:text-amber-800">Add Amendment</h3>
<p class="mt-1 text-xs text-slate-500 line-clamp-2">
Propose a revision or improvement
</p>
</article>
</div>

<button
class="flex items-center justify-center absolute top-1/2 right-0 border-2 border-gray-700 -translate-y-1/2 -translate-x-1/3 z-10 bg-white rounded-full shadow-md w-10 h-10"
@click="scrollRight"
v-show="!scrolledMax()"
v-show="canScrollRight"
type="button"
class="carousel-btn right-0 -translate-x-1/3"
aria-label="Scroll amendments right"
@click="scrollBy(1)"
>
<Arrow/>
<Arrow class="size-4" />
</button>
</div>
</template>

<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue";
import Arrow from "./icons/Arrow.vue";
import Plus from "./icons/Plus.vue";
import Confirm from "./icons/Confirm.vue";
import Cancel from "./icons/Cancel.vue";
import {computed, onMounted, ref, toRefs, watch} from "vue";
import {Amendment} from "../types/amendment.ts";
import Chip from "./Chip.vue";
import {ContentModal, useModal} from "../composables/useModal.ts";
import AmendmentModal from "./modals/AmendmentModal.vue";
import {getChipClass} from "../utils/amendment.ts";
import {AmendmentService} from "../generated/typescript";
import NewAmendmentModal from "./modals/NewAmendmentModal.vue";
import {AmendmentService, AmendmentWithSummary, UserAmendmentVote} from "../generated/typescript";
import { getChipClass } from "../utils/amendment.ts";
import { getUserIdFromJwt } from "../utils/auth.ts";
import { ContentModal, useModal } from "../composables/useModal.ts";
import { eventBus } from "../utils/eventBus.ts";

const props = defineProps<{proposalId?: number}>();
const {proposalId} = toRefs(props);
const props = defineProps<{
proposalId?: number;
amendmentVotes?: UserAmendmentVote[];
}>();
const emit = defineEmits<{
(e: 'voted'): void;
}>();
const { proposalId } = toRefs(props);

const offset = ref(0);
const amendments = ref<Amendment[]>();
const {show: showModal, setAmendmentContent} = useModal();
const getAmendmentVote = (amendmentId: number): boolean | null | undefined => {
if (!props.amendmentVotes) return undefined;
const match = props.amendmentVotes.find((v) => v.amendmentId === amendmentId);
return match?.vote ? match.vote.inFavor : undefined;
};

const showAmendment = async (amendmentId: number, status: string) => {
const resp = await AmendmentService.getAmendment(amendmentId);
setAmendmentContent(resp.amendment.name, status, resp.amendment.content);
showModal(AmendmentModal as ContentModal);
}
const amendments = ref<AmendmentWithSummary[]>([]);
const scroller = ref<HTMLElement>();
const canScrollLeft = ref(false);
const canScrollRight = ref(false);
const loading = ref(false);
const error = ref<string>();

const getAmendments = async () => {
if (!props.proposalId) return;
const resp = await AmendmentService.listAmendments(props.proposalId)
amendments.value = resp.amendments;
}
const { show: showModal, setContent, addContent } = useModal();

const tray = ref<HTMLElement>();
const trayWidth = ref(0);
const updateEdges = () => {
const el = scroller.value;
if (!el) {
canScrollLeft.value = false;
canScrollRight.value = false;
return;
}
const max = el.scrollWidth - el.clientWidth;
canScrollLeft.value = el.scrollLeft > 8;
canScrollRight.value = max > 8 && el.scrollLeft < max - 8;
};

onMounted(async () => {
await getAmendments();
if (!tray.value) return;
trayWidth.value = tray.value.clientWidth;
});
const scrollByDir = (direction: -1 | 1) => {
const el = scroller.value;
if (!el) return;
el.scrollBy({ left: direction * el.clientWidth * 0.8, behavior: "smooth" });
};

const determineOffsetChange = () => {
const width = window.innerWidth;
if (width < 768) return 156;
else if (width < 1024) return 300;
else return 332;
}
const scrollBy = scrollByDir;

const scrollLeft = () => {
if (!tray.value) return;
if (!trayWidth.value) trayWidth.value = tray.value.clientWidth;
offset.value = Math.max(offset.value - trayWidth.value * 0.6, 0);
}
let resizeObserver: ResizeObserver | undefined;

const scrollRight = () => {
if (!tray.value) return;
if (!trayWidth.value) trayWidth.value = tray.value.clientWidth;
offset.value = Math.min(offset.value + trayWidth.value * 0.6, numberOfAmendments.value * determineOffsetChange() - trayWidth.value)
}
const getAmendments = async () => {
if (!props.proposalId) {
amendments.value = [];
return;
}
loading.value = true;
error.value = undefined;
try {
const resp = await AmendmentService.listAmendments(props.proposalId);
amendments.value = resp ?? [];
await nextTick();
updateEdges();
} catch (e) {
amendments.value = [];
error.value = e instanceof Error ? e.message : "Could not load amendments";
} finally {
loading.value = false;
}
};

const scrolledNone = () => {
if (!tray.value) return;
return offset.value < 8;
}
const showAmendment = async (id: number) => {
const resp = await AmendmentService.getAmendment(id);
setContent('name', resp.name, 'heading');
setContent('status', resp.status, 'chip');
resp.paragraphs.forEach((p) => {
addContent({
key: "body",
value: p.paragraphText,
format: "paragraph",
});
});
showModal(AmendmentModal as ContentModal);
};

const scrolledMax = () => {
if (!tray.value) return;
if (!trayWidth.value) trayWidth.value = tray.value.clientWidth;
return offset.value >= numberOfAmendments.value * determineOffsetChange() - trayWidth.value - 8;
}
const openNewAmendmentModal = () => {
if (props.proposalId) {
setContent("proposalId", props.proposalId);
}
showModal(NewAmendmentModal as unknown as ContentModal);
};

const confirm = async (amendmentId: number) => {
const userId = getUserIdFromJwt();
if (!userId) {
console.error("User is not authenticated or user ID is missing from JWT.");
error.value = "Please connect your wallet to vote on amendments.";
return;
}
try {
await AmendmentService.voteAmendment({
amendmentId,
inFavor: true,
});
await getAmendments();
emit('voted');
} catch (e) {
console.error("Failed to confirm amendment:", e);
}
};

const reject = async (amendmentId: number) => {
const userId = getUserIdFromJwt();
if (!userId) {
console.error("User is not authenticated or user ID is missing from JWT.");
error.value = "Please connect your wallet to vote on amendments.";
return;
}
try {
await AmendmentService.voteAmendment({
amendmentId,
inFavor: false,
});
await getAmendments();
emit('voted');
} catch (e) {
console.error("Failed to reject amendment:", e);
}
};

const numberOfAmendments = computed(() => amendments.value?.length || 0);
const handleAmendmentCreated = (id?: number) => {
if (!id || id === props.proposalId) {
getAmendments();
}
};

watch(proposalId, async (newVal) => {
if (!newVal) return;
onMounted(async () => {
await getAmendments();
eventBus.on("amendmentCreated", handleAmendmentCreated);
if (!scroller.value) return;
resizeObserver = new ResizeObserver(updateEdges);
resizeObserver.observe(scroller.value);
updateEdges();
});

onBeforeUnmount(() => resizeObserver?.disconnect());

watch(proposalId, async (id) => {
if (!id) return;
if (scroller.value) scroller.value.scrollLeft = 0;
await getAmendments();
offset.value = 0;
})
});
</script>

<style scoped>
.tray {
transition: transform 0.5s ease;
<style lang="postcss" scoped>
.carousel-btn {
@apply absolute top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-slate-300 bg-white/95 text-slate-700 shadow-md backdrop-blur-sm transition hover:border-slate-400 hover:bg-slate-50 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-900;
}
</style>

+ 13
- 25
src/components/BrandBar.vue Bestand weergeven

@@ -10,8 +10,9 @@
<button
class="text-xs ml-auto rounded-md bg-white text-gray-900 font-semibold px-5 py-2 shadow-sm hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-300 transition-colors duration-200"
type="button"
:disabled="isConnecting"
@click="connectOrDisplayUser">
{{ userText }}
{{ buttonText }}
</button>
</div>
</div>
@@ -19,33 +20,20 @@

<script setup lang="ts">
import ColoredHeader from "./ColoredHeader.vue";
import { ref } from "vue";
import freighter, { setAllowed } from "@stellar/freighter-api";
import { truncateWallet } from "../utils/wallet.ts";
import {AuthService} from "../generated/typescript";
import { computed } from "vue";
import { useSession } from "../composables/useSession";

const userText = ref('Connect Wallet');
const { isAuthenticated, truncatedAddress, isConnecting, connectWallet } = useSession();

const connectOrDisplayUser = async () => {
try {
await setAllowed();
const addr = await freighter.getAddress();
if (!addr?.address) return;

// Send the Stellar address to the backend to obtain a JWT string
const jwt = await AuthService.loginFreighter({stellarAddress: addr.address});
const buttonText = computed(() => {
if (isConnecting.value) return "Connecting...";
if (isAuthenticated.value && truncatedAddress.value) return truncatedAddress.value;
return "Connect Wallet";
});

// Optionally store JWT for future authenticated requests
try {
localStorage.setItem("authToken", jwt.token as string);
} catch (_) {
// storage may not be available; ignore
}

// Update the button text to show the connected wallet (truncated)
userText.value = truncateWallet(addr.address);
} catch (e) {
console.error("Failed to connect or authenticate with Freighter:", e);
const connectOrDisplayUser = async () => {
if (!isAuthenticated.value) {
await connectWallet();
}
};
</script>


+ 189
- 0
src/components/CommentThread.vue Bestand weergeven

@@ -0,0 +1,189 @@
<template>
<article
class="rounded-lg border border-slate-200 bg-white p-4 shadow-sm"
:aria-label="`Comment by ${getCommentAuthor(node.comment)}`"
>
<header class="mb-2 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 text-xs">
<span class="font-semibold text-slate-900">
{{ getCommentAuthor(node.comment) }}
</span>
<span v-if="node.comment.username && node.comment.fullName" class="text-slate-500">
@{{ node.comment.username }}
</span>
</div>
<time
v-if="node.comment.createdAt"
:datetime="node.comment.createdAt"
class="text-[0.7rem] text-slate-500"
>
{{ formatDate(node.comment.createdAt) }}
</time>
</header>

<p class="whitespace-pre-wrap text-sm text-slate-800 leading-relaxed">
{{ node.comment.content }}
</p>

<div class="mt-2">
<button
v-if="!isReplying"
type="button"
@click="openReply"
class="text-xs font-semibold text-slate-500 hover:text-amber-600 focus:outline-none focus:ring-2 focus:ring-amber-500/20 rounded"
>
Reply
</button>
</div>

<!-- Inline Reply Form -->
<form
v-if="isReplying"
@submit.prevent="submitReply"
class="mt-3 space-y-2"
:aria-label="`Reply to ${getCommentAuthor(node.comment)}`"
>
<div v-if="replyError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">
{{ replyError }}
</div>

<label :for="`comment-${node.comment.id}-reply`" class="sr-only">
Reply to {{ getCommentAuthor(node.comment) }}
</label>
<textarea
:id="`comment-${node.comment.id}-reply`"
ref="replyInput"
v-model="replyText"
rows="2"
:disabled="isSubmitting"
:placeholder="`Reply to ${getCommentAuthor(node.comment)}...`"
class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y"
></textarea>

<div class="flex items-center justify-end gap-2">
<button
type="button"
:disabled="isSubmitting"
@click="cancelReply"
class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
:disabled="isSubmitDisabled"
class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
<span v-if="isSubmitting">Posting...</span>
<span v-else>Reply</span>
</button>
</div>
</form>

<!-- Replies -->
<div
v-if="visibleReplies.length > 0"
class="mt-4 space-y-3 border-l-2 border-slate-200 pl-4"
>
<CommentThread
v-for="reply in visibleReplies"
:key="reply.comment.id"
:node="reply"
:post-reply="postReply"
:nesting-level="nestingLevel + 1"
/>
</div>
</article>
</template>

<script setup lang="ts">
import {computed, nextTick, ref} from "vue";
import {CommentWithUser} from "../generated/typescript";
import {CommentNode} from "../utils/comments.ts";

const props = defineProps<{
node: CommentNode;
postReply: (parentId: number, content: string) => Promise<void>;
nestingLevel: number;
}>();

const isReplying = ref(false);
const replyText = ref("");
const isSubmitting = ref(false);
const replyError = ref<string | null>(null);
const replyInput = ref<HTMLTextAreaElement | null>(null);

// Visual nesting levels are 1-based; nestingLevel is 0 for top-level comments.
const MAX_NESTING_LEVEL = 3;

const flattenReplies = (replies: CommentNode[]): CommentNode[] =>
replies.flatMap((reply) => [reply, ...flattenReplies(reply.replies)]);

// Comments at the max level never render their own replies. Instead, the comment one
// level above flattens its entire reply subtree (depth-first, so each conversation stays
// together), placing every deeper reply at the max level.
const visibleReplies = computed(() => {
const level = props.nestingLevel + 1;
if (level >= MAX_NESTING_LEVEL) return [];
if (level === MAX_NESTING_LEVEL - 1) return flattenReplies(props.node.replies);
return props.node.replies;
});

const isSubmitDisabled = computed(() => {
return !replyText.value.trim() || isSubmitting.value;
});

const formatDate = (dateString?: string) => {
if (!dateString) return "";
try {
const d = new Date(dateString);
if (isNaN(d.getTime())) return dateString;
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateString;
}
};

const getCommentAuthor = (comment: CommentWithUser) => {
return comment.fullName?.trim() || comment.username?.trim() || (comment.userId ? `User #${comment.userId}` : "Community Member");
};

const openReply = async () => {
isReplying.value = true;
await nextTick();
replyInput.value?.focus();
};

const cancelReply = () => {
isReplying.value = false;
replyText.value = "";
replyError.value = null;
};

const submitReply = async () => {
if (isSubmitDisabled.value) return;

const content = replyText.value.trim();
if (!content) return;

isSubmitting.value = true;
replyError.value = null;

try {
await props.postReply(props.node.comment.id, content);
cancelReply();
} catch (err) {
console.error("Failed to post reply:", err);
replyError.value =
err instanceof Error ? err.message : "Failed to post reply. Please try again.";
} finally {
isSubmitting.value = false;
}
};
</script>

+ 215
- 12
src/components/ProposalDetail.vue Bestand weergeven

@@ -86,7 +86,11 @@
</span>
</div>

<AmendmentCarousel :proposal-id="proposal.id" />
<AmendmentCarousel
:proposal-id="proposal.id"
:amendment-votes="userVotes?.amendmentVotes"
@voted="fetchUserVotes(proposal.id)"
/>
</section>

<section
@@ -108,12 +112,76 @@
</span>
</div>

<div
v-if="false"
class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8
text-sm text-slate-500"
>
Discussion will appear here.
<!-- Inline Comment Form -->
<form @submit.prevent="submitComment" class="mb-8 space-y-3" aria-label="Add a comment">
<div v-if="commentError" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">
{{ commentError }}
</div>

<div>
<label for="proposal-comment-input" class="sr-only">
Add to the discussion
</label>
<textarea
id="proposal-comment-input"
v-model="commentText"
rows="3"
:disabled="isSubmitting"
placeholder="Add to the discussion..."
class="w-full rounded-lg border border-slate-300 p-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10 disabled:bg-slate-50 disabled:text-slate-400 resize-y"
></textarea>
</div>

<div class="flex items-center justify-between gap-3">
<span class="text-xs text-slate-500">
Share your perspective on this proposal.
</span>
<div class="flex items-center gap-2">
<button
v-if="commentText.trim().length > 0"
type="button"
:disabled="isSubmitting"
@click="handleCancel"
class="rounded-lg border border-slate-300 px-3.5 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-300 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
:disabled="isSubmitDisabled"
class="rounded-lg bg-amber-500 px-4 py-2 text-xs font-semibold text-white shadow-sm hover:bg-amber-600 focus:outline-none focus:ring-4 focus:ring-amber-500/20 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 transition-colors"
>
<span v-if="isSubmitting">Posting...</span>
<span v-else>Add Comment</span>
</button>
</div>
</div>
</form>

<!-- Comments List -->
<div class="space-y-4">
<!-- Only show the loading state on an empty list, so refetching after a reply
doesn't unmount the threads (and their open reply forms) mid-submit. -->
<div v-if="isLoadingComments && comments.length === 0" class="py-6 text-center text-sm text-slate-500">
Loading comments...
</div>

<div
v-else-if="comments.length === 0"
class="rounded-lg border border-dashed border-slate-300 bg-white/50 px-5 py-8 text-center text-sm text-slate-500"
>
No comments yet. Be the first to share your feedback!
</div>

<div v-else class="space-y-3">
<CommentThread
v-for="node in commentTree"
:key="node.comment.id"
:node="node"
:post-reply="postComment"
:nesting-level="0"
/>
</div>
</div>
</section>
</div>
@@ -123,7 +191,11 @@
class="xl:sticky xl:top-6"
aria-labelledby="vote-heading"
>
<ProposalVotingCard :proposal-id="proposal.id" />
<ProposalVotingCard
:proposal-id="proposal.id"
:current-vote="userVotes?.proposalVote?.choice"
@vote="recordVote"
/>
<ProposalExportCard :proposal-id="proposal.id" :proposal-name="proposal.name" />
</aside>
</div>
@@ -131,15 +203,146 @@
</template>

<script setup lang="ts">
import { ProposalWithParagraphs } from "../generated/typescript";
import { truncateWallet } from "../utils/wallet.ts";
import { renderMarkdown } from "../utils/markdown.ts";
import {computed, onMounted, ref, watch} from "vue";
import {
CommentService,
type CommentWithUser,
ProposalService,
ProposalWithParagraphs,
UserProposalVotes,
VoteChoice,
} from "../generated/typescript";
import {truncateWallet} from "../utils/wallet.ts";
import {renderMarkdown} from "../utils/markdown.ts";
import Identification from "./icons/Identification.vue";
import AmendmentCarousel from "./AmendmentCarousel.vue";
import ProposalVotingCard from "./ProposalVotingCard.vue";
import ProposalExportCard from "./ProposalExportCard.vue";
import CommentThread from "./CommentThread.vue";
import {getUserIdFromJwt} from "../utils/auth.ts";
import {buildCommentTree} from "../utils/comments.ts";

defineProps<{
const props = defineProps<{
proposal: ProposalWithParagraphs;
}>();

const commentText = ref("");
const isSubmitting = ref(false);
const commentError = ref<string | null>(null);
const comments = ref<CommentWithUser[]>([]);
const isLoadingComments = ref(false);

const userVotes = ref<UserProposalVotes | null>(null);

const commentTree = computed(() => buildCommentTree(comments.value));

const isSubmitDisabled = computed(() => {
return !commentText.value.trim() || isSubmitting.value;
});

const fetchUserVotes = async (proposalId?: number) => {
if (proposalId === undefined || proposalId === null || isNaN(proposalId)) {
userVotes.value = null;
return;
}
try {
const res = await ProposalService.getUserVotes(proposalId);
userVotes.value = res ?? null;
} catch {
userVotes.value = null;
}
};

const recordVote = async (vote: 'Positive' | 'Negative' | 'Abstain') => {
const userId = getUserIdFromJwt();
let choice: VoteChoice | undefined;
if (vote == 'Positive') choice = VoteChoice.YES;
else if (vote == 'Negative') choice = VoteChoice.NO;
else if (vote == 'Abstain') choice = VoteChoice.ABSTAIN;
if (choice !== undefined && userId !== undefined) {
try {
await ProposalService.voteProposal({
proposalId: props.proposal.id,
choice,
});
await fetchUserVotes(props.proposal.id);
} catch (err) {
console.error("Failed to vote proposal:", err);
}
}
};

const handleCancel = () => {
commentText.value = "";
commentError.value = null;
};

const fetchComments = async (proposalId?: number) => {
if (proposalId === undefined || proposalId === null || isNaN(proposalId)) {
comments.value = [];
return;
}
isLoadingComments.value = true;
try {
const res = await CommentService.listComments(proposalId);
comments.value = res ?? [];
} catch (err) {
console.error("Failed to load comments:", err);
comments.value = [];
} finally {
isLoadingComments.value = false;
}
};

// A null parentId posts a top-level comment; otherwise it's a reply to that comment.
const postComment = async (parentId: number | null, content: string) => {
await CommentService.addComment({
proposalId: props.proposal.id,
content,
parentId,
userId: 1,
});
await fetchComments(props.proposal.id);
};

const submitComment = async () => {
if (isSubmitDisabled.value) return;

const content = commentText.value.trim();
if (!content) return;

isSubmitting.value = true;
commentError.value = null;

try {
await postComment(null, content);
commentText.value = "";
} catch (err) {
console.error("Failed to add comment:", err);
commentError.value =
err instanceof Error ? err.message : "Failed to post comment. Please try again.";
} finally {
isSubmitting.value = false;
}
};

onMounted(() => {
if (props.proposal?.id) {
fetchComments(props.proposal.id);
fetchUserVotes(props.proposal.id);
}
});

watch(
() => props.proposal?.id,
(newId) => {
if (newId) {
fetchComments(newId);
fetchUserVotes(newId);
} else {
comments.value = [];
userVotes.value = null;
}
}
);
</script>

+ 26
- 16
src/components/ProposalVotingCard.vue Bestand weergeven

@@ -19,16 +19,17 @@
<div class="space-y-2 p-3">
<button
type="button"
:aria-pressed="isApproved"
@click="emit('vote', 'Positive')"
class="group flex w-full items-center gap-3 rounded-lg border border-slate-300
bg-white px-3 py-2.5 text-left text-slate-700 transition
hover:border-green-600 hover:bg-green-600 hover:text-white
class="group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition
focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-slate-950"
:class="isApproved
? 'border-emerald-600 bg-emerald-50 text-emerald-700 ring-1 ring-emerald-500'
: 'border-slate-300 bg-white text-slate-700 hover:border-green-600 hover:bg-green-600 hover:text-white'"
>
<Confirm
class="size-5 shrink-0 fill-current"
:box="96"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span class="font-semibold">Approve</span>
@@ -36,16 +37,17 @@

<button
type="button"
:aria-pressed="isRejected"
@click="emit('vote', 'Negative')"
class="group flex w-full items-center gap-3 rounded-lg border border-slate-300
bg-white px-3 py-2.5 text-left text-slate-700 transition
hover:border-red-600 hover:bg-red-600 hover:text-white
class="group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition
focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-slate-950"
:class="isRejected
? 'border-rose-600 bg-rose-50 text-rose-700 ring-1 ring-rose-500'
: 'border-slate-300 bg-white text-slate-700 hover:border-red-600 hover:bg-red-600 hover:text-white'"
>
<Cancel
class="size-5 shrink-0 fill-current"
:box="96"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span class="font-semibold">Reject</span>
@@ -53,16 +55,17 @@

<button
type="button"
:aria-pressed="isAbstained"
@click="emit('vote', 'Abstain')"
class="group flex w-full items-center gap-3 rounded-lg border border-slate-300
bg-white px-3 py-2.5 text-left text-slate-700 transition
hover:border-amber-500 hover:bg-amber-500 hover:text-slate-950
class="group flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition
focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-slate-950"
:class="isAbstained
? 'border-amber-500 bg-amber-50 text-amber-700 ring-1 ring-amber-500'
: 'border-slate-300 bg-white text-slate-700 hover:border-amber-500 hover:bg-amber-500 hover:text-slate-950'"
>
<Abstain
class="size-5 shrink-0 fill-current"
:box="96"
class="size-5 shrink-0"
aria-hidden="true"
/>
<span class="font-semibold">Abstain</span>
@@ -79,15 +82,22 @@
</template>

<script setup lang="ts">
import { computed } from "vue";
import Confirm from "./icons/Confirm.vue";
import Cancel from "./icons/Cancel.vue";
import Abstain from "./icons/Abstain.vue";
import { VoteChoice } from "../generated/typescript";

defineProps<{
const props = defineProps<{
proposalId?: number;
currentVote?: VoteChoice | 'Positive' | 'Negative' | 'Abstain' | null;
}>();

const emit = defineEmits<{
(e: 'vote', type: 'Positive' | 'Negative' | 'Abstain'): void;
}>();

const isApproved = computed(() => props.currentVote === VoteChoice.YES || props.currentVote === 'Positive');
const isRejected = computed(() => props.currentVote === VoteChoice.NO || props.currentVote === 'Negative');
const isAbstained = computed(() => props.currentVote === VoteChoice.ABSTAIN || props.currentVote === 'Abstain');
</script>

+ 4
- 3
src/components/Sidebar.vue Bestand weergeven

@@ -1,5 +1,5 @@
<template>
<aside class="sm:flex sm:flex-col sm:w-1/4 bg-gray-600 h-screen">
<aside class="sm:flex sm:flex-col bg-gray-600 h-screen">
<nav class="sticky top-0 left-0 flex flex-row w-full h-1/16 justify-between">
<button
class="w-10/12 h-full bg-gray-800 px-5 py-3 text-sm font-bold text-white border-r border-white border-solid uppercase text-center cursor-pointer"
@@ -8,10 +8,11 @@
Add Proposal
</button>
<button
class="flex flex-row w-2/12 h-full bg-gray-800 justify-center items-center cursor-pointer"
class="flex flex-row w-2/12 h-full bg-gray-800 justify-center items-center cursor-pointer hover:bg-gray-700 transition-colors"
@click="showFilterModal"
aria-label="Filter proposals"
>
<Filter/>
<Filter class="size-4 text-white" />
</button>
</nav>



+ 0
- 65
src/components/VotingControls.vue Bestand weergeven

@@ -1,65 +0,0 @@
<template>
<div class="flex flex-row justify-around">
<div>
<Confirm
:width="24" :height="24" :box="96"
class="inline-block fill-gray-50 hover:fill-green-500 cursor-pointer mx-2"
:class="{'fill-green-500': vote === 'Positive'}"
@click="recordAccept"
/>
<span class="text-white align-bottom font-poppins hidden sm:inline-block">Approve</span>
</div>
<div>
<Cancel
:width="24" :height="24" :box="96"
class="inline-block fill-gray-50 hover:fill-red-500 cursor-pointer mx-2"
:class="{'fill-red-500': vote === 'Negative'}"
@click="recordDecline"
/>
<span class="text-white align-bottom font-poppins hidden sm:inline-block">Reject</span>
</div>
<div>
<Abstain
:width="24" :height="24" :box="96"
class="inline-block fill-gray-50 hover:fill-amber-500 cursor-pointer mx-2"
@click="recordAbstain"
/>
<span class="text-white align-bottom font-poppins hidden sm:inline-block">Abstain</span>
</div>
</div>
</template>

<script setup lang="ts">
import {VoteProposalRequest, VoteProposalResponse} from "../types/proposal.ts";
import Cancel from "./icons/Cancel.vue";
import Confirm from "./icons/Confirm.vue";
import freighter from "@stellar/freighter-api";
import Abstain from "./icons/Abstain.vue";

const props = defineProps<{ selectedId: string, vote?: 'Positive' | 'Negative' }>();

const recordAccept = async () => {
if (!props.selectedId) return;
const addr = await freighter.getAddress();
// TODO: replace this call
await post<VoteProposalRequest, VoteProposalResponse>('/issues/vote',
{ proposal_id: props.selectedId.toString(), wallet: addr.address, vote: "Positive" })
}
const recordDecline = async () => {
if (!props.selectedId) return;
const addr = await freighter.getAddress();
// TODO: replace this call
await post<VoteProposalRequest, VoteProposalResponse>('/issues/vote',
{ proposal_id: props.selectedId, wallet: addr.address, vote: "Negative" })
}
const recordAbstain = async () => {
if (!props.selectedId) return;
const addr = await freighter.getAddress();
}
</script>

<style lang="postcss">
.font-poppins {
font-family: 'Poppins', sans-serif;
}
</style>

+ 7
- 18
src/components/icons/Abstain.vue Bestand weergeven

@@ -1,28 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:viewBox="`0 0 ${box} ${box}`"
:width="width"
:height="height"
xml:space="preserve"
>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/>
<path d="M60,42H36a6,6,0,0,0,0,12H60a6,6,0,0,0,0-12Z"/>
</g>
</svg>
</IconBase>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), {
box: 120,
width: 20,
height: 20,
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 96 96',
});
const { box, width, height } = props;
</script>

<style scoped>

</style>

+ 55
- 0
src/components/icons/AppIcon.vue Bestand weergeven

@@ -0,0 +1,55 @@
<template>
<component
:is="iconComponent"
:size="size"
:view-box="viewBox"
:title="title"
:aria-hidden="ariaHidden"
:role="role"
/>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import type { IconName, IconBaseProps } from './types';
import Abstain from './Abstain.vue';
import Arrow from './Arrow.vue';
import Cancel from './Cancel.vue';
import Confirm from './Confirm.vue';
import DownArrow from './DownArrow.vue';
import Download from './Download.vue';
import Filter from './Filter.vue';
import Identification from './Identification.vue';
import Menu from './Menu.vue';
import Plus from './Plus.vue';
import SpeechBubbles from './SpeechBubbles.vue';
import Stack from './Stack.vue';
import ThumbsDown from './ThumbsDown.vue';
import ThumbsUp from './ThumbsUp.vue';

interface Props extends IconBaseProps {
name: IconName;
}

const props = defineProps<Props>();

const icons: Record<IconName, Component> = {
abstain: Abstain,
arrow: Arrow,
cancel: Cancel,
confirm: Confirm,
'down-arrow': DownArrow,
download: Download,
filter: Filter,
identification: Identification,
menu: Menu,
plus: Plus,
'speech-bubbles': SpeechBubbles,
stack: Stack,
'thumbs-down': ThumbsDown,
'thumbs-up': ThumbsUp,
};

const iconComponent = computed(() => icons[props.name]);
</script>

+ 8
- 7
src/components/icons/Arrow.vue Bestand weergeven

@@ -1,13 +1,14 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 320 500" style="width: 10px; height: 20px;" xml:space="preserve">
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<path d="M285.476 272.971L91.132 467.314c-9.373 9.373-24.569 9.373-33.941 0l-22.667-22.667c-9.357-9.357-9.375-24.522-.04-33.901L188.505 256 34.484 101.255c-9.335-9.379-9.317-24.544.04-33.901l22.667-22.667c9.373-9.373 24.569-9.373 33.941 0L285.475 239.03c9.373 9.372 9.373 24.568.001 33.941z"/>
</svg>
</IconBase>
</template>

<script setup lang="ts">
</script>

<style scoped>
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

</style>
withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 320 500',
});
</script>

+ 7
- 18
src/components/icons/Cancel.vue Bestand weergeven

@@ -1,28 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:viewBox="`0 0 ${box} ${box}`"
:width="width"
:height="height"
xml:space="preserve"
>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/>
<path d="M64.2422,31.7578a5.9979,5.9979,0,0,0-8.4844,0L48,39.5156l-7.7578-7.7578a5.9994,5.9994,0,0,0-8.4844,8.4844L39.5156,48l-7.7578,7.7578a5.9994,5.9994,0,1,0,8.4844,8.4844L48,56.4844l7.7578,7.7578a5.9994,5.9994,0,0,0,8.4844-8.4844L56.4844,48l7.7578-7.7578A5.9979,5.9979,0,0,0,64.2422,31.7578Z"/>
</g>
</svg>
</IconBase>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), {
box: 120,
width: 20,
height: 20,
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 96 96',
});
const { box, width, height } = props;
</script>

<style scoped>

</style>

+ 7
- 18
src/components/icons/Confirm.vue Bestand weergeven

@@ -1,28 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
:viewBox="`0 0 ${box} ${box}`"
:width="width"
:height="height"
xml:space="preserve"
>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M58.3945,32.1563,42.9961,50.625l-5.3906-6.4629a5.995,5.995,0,1,0-9.211,7.6758l9.9961,12a5.9914,5.9914,0,0,0,9.211.0059l20.0039-24a5.9988,5.9988,0,1,0-9.211-7.6875Z"/>
<path d="M48,0A48,48,0,1,0,96,48,48.0512,48.0512,0,0,0,48,0Zm0,84A36,36,0,1,1,84,48,36.0393,36.0393,0,0,1,48,84Z"/>
</g>
</svg>
</IconBase>
</template>

<script setup lang="ts">
const props = withDefaults(defineProps<{ box?: number; width?: number | string; height?: number | string }>(), {
box: 120,
width: 20,
height: 20,
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 96 96',
});
const { box, width, height } = props;
</script>

<style scoped>

</style>

+ 10
- 15
src/components/icons/DownArrow.vue Bestand weergeven

@@ -1,21 +1,16 @@
<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg id="down-arrow" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve">
<g id="Down_Arrow">
<path d="M29.4,2.8c-0.1-0.2-0.3-0.3-0.4-0.3H1c-0.2,0-0.3,0.1-0.4,0.3s-0.1,0.3,0,0.5l14,24c0.1,0.2,0.3,0.2,0.4,0.2
s0.3-0.1,0.4-0.2l14-24C29.5,3.1,29.5,2.9,29.4,2.8z"/>
</g>
</svg>
</div>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g id="Down_Arrow">
<path d="M29.4,2.8c-0.1-0.2-0.3-0.3-0.4-0.3H1c-0.2,0-0.3,0.1-0.4,0.3s-0.1,0.3,0,0.5l14,24c0.1,0.2,0.3,0.2,0.4,0.2 s0.3-0.1,0.4-0.2l14-24C29.5,3.1,29.5,2.9,29.4,2.8z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

<style scoped>
#down-arrow:hover {
fill: #dedede !important;
}
</style>

+ 18
- 10
src/components/icons/Download.vue Bestand weergeven

@@ -1,18 +1,26 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
<IconBase
:view-box="viewBox"
:size="size"
:title="title"
:aria-hidden="ariaHidden"
:role="role"
class="fill-none stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 24 24',
});
</script>

+ 10
- 19
src/components/icons/Filter.vue Bestand weergeven

@@ -1,25 +1,16 @@
<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg id="filter-button" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve">
<g>
<path d="M28.8,0.9C28.5,0.4,27.9,0,27.3,0L2.7,0C2.1,0,1.5,0.4,1.2,1C0.9,1.6,0.9,2.2,1.3,2.8l9.6,14c0.1,0.1,0.1,0.3,0.1,0.4v11
c0,1,0.8,1.7,1.8,1.7c0.3,0,0.6-0.1,0.9-0.2l4.5-2.3c0.5-0.3,0.8-0.9,0.8-1.5V17c0-0.2,0-0.3,0.1-0.4l2.6-3.7c0,0,0,0,0.1,0.1
l1.5,0.8c0.3,0.2,0.6,0.3,0.9,0.3c0.5,0,1.1-0.2,1.4-0.7l2.1-2.7c0.3-0.4,0.4-0.9,0.3-1.4s-0.4-0.9-0.8-1.1l-1.4-1c0,0-0.1,0-0.1,0
l3-4.3C29.1,2.2,29.1,1.5,28.8,0.9z M12.8,13.9C12.7,14,12.6,14,12.5,14c-0.2,0-0.3-0.1-0.4-0.2l-7-10C4.9,3.6,5,3.2,5.2,3.1
C5.4,2.9,5.8,3,5.9,3.2l7,10C13.1,13.4,13,13.8,12.8,13.9z M26.6,8.9C26.8,9,26.9,9.2,27,9.4s0,0.4-0.1,0.6l-2.1,2.7
c-0.2,0.3-0.7,0.4-1,0.2l-1.5-0.8l2.9-4.2c0,0,0,0,0.1,0.1L26.6,8.9z"/>
</g>
</svg>
</div>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M28.8,0.9C28.5,0.4,27.9,0,27.3,0L2.7,0C2.1,0,1.5,0.4,1.2,1C0.9,1.6,0.9,2.2,1.3,2.8l9.6,14c0.1,0.1,0.1,0.3,0.1,0.4v11 c0,1,0.8,1.7,1.8,1.7c0.3,0,0.6-0.1,0.9-0.2l4.5-2.3c0.5-0.3,0.8-0.9,0.8-1.5V17c0-0.2,0-0.3,0.1-0.4l2.6-3.7c0,0,0,0,0.1,0.1 l1.5,0.8c0.3,0.2,0.6,0.3,0.9,0.3c0.5,0,1.1-0.2,1.4-0.7l2.1-2.7c0.3-0.4,0.4-0.9,0.3-1.4s-0.4-0.9-0.8-1.1l-1.4-1c0,0-0.1,0-0.1,0 l3-4.3C29.1,2.2,29.1,1.5,28.8,0.9z M12.8,13.9C12.7,14,12.6,14,12.5,14c-0.2,0-0.3-0.1-0.4-0.2l-7-10C4.9,3.6,5,3.2,5.2,3.1 C5.4,2.9,5.8,3,5.9,3.2l7,10C13.1,13.4,13,13.8,12.8,13.9z M26.6,8.9C26.8,9,26.9,9.2,27,9.4s0,0.4-0.1,0.6l-2.1,2.7 c-0.2,0.3-0.7,0.4-1,0.2l-1.5-0.8l2.9-4.2c0,0,0,0,0.1,0.1L26.6,8.9z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

<style scoped>
#filter-button:hover {
fill: #cfcfcf !important;
}
</style>

+ 40
- 0
src/components/icons/IconBase.vue Bestand weergeven

@@ -0,0 +1,40 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
:viewBox="viewBox"
:width="computedSize"
:height="computedSize"
:aria-hidden="title ? undefined : (ariaHidden ? 'true' : undefined)"
:aria-label="title"
:role="title ? 'img' : (role || 'presentation')"
fill="currentColor"
class="inline-block shrink-0 align-middle select-none"
>
<title v-if="title">{{ title }}</title>
<slot />
</svg>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type { IconBaseProps } from './types';

const props = withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 24 24',
ariaHidden: true,
size: 'md',
});

const sizeMap: Record<string, string> = {
xs: '12',
sm: '16',
md: '20',
lg: '24',
xl: '32',
};

const computedSize = computed(() => {
if (typeof props.size === 'number') return props.size;
return sizeMap[props.size] || props.size;
});
</script>

+ 18
- 25
src/components/icons/Identification.vue Bestand weergeven

@@ -1,31 +1,24 @@
<script setup lang="ts">

</script>

<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<g>
<path d="M28.8,7.1c-0.2-0.1-0.4-0.1-0.5,0C28.1,7.1,28,7.3,28,7.5V20c0,1.9-1.6,3.5-3.5,3.5H3c-0.2,0-0.4,0.1-0.4,0.3 c-0.1,0.2-0.1,0.4,0,0.5c0.6,0.8,1.4,1.2,2.4,1.2h22c1.7,0,3-1.3,3-3v-13C30,8.6,29.6,7.7,28.8,7.1z"/>
</g>
<g>
<path d="M27,20V7c0-1.4-1.1-2.5-2.5-2.5h-22C1.1,4.5,0,5.6,0,7v13c0,1.4,1.1,2.5,2.5,2.5h22C25.9,22.5,27,21.4,27,20z M18.5,9.5h6 c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,9.5,18.5,9.5z M18.5,13.5h6c0.3,0,0.5,0.2,0.5,0.5 s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,13.5,18.5,13.5z M9,20c-3.6,0-6.5-2.9-6.5-6.5S5.4,7,9,7s6.5,2.9,6.5,6.5 S12.6,20,9,20z M18,18c0-0.3,0.2-0.5,0.5-0.5h6c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6C18.2,18.5,18,18.3,18,18z"/>
</g>
<g>
<g>
<path d="M28.8,7.1c-0.2-0.1-0.4-0.1-0.5,0C28.1,7.1,28,7.3,28,7.5V20c0,1.9-1.6,3.5-3.5,3.5H3c-0.2,0-0.4,0.1-0.4,0.3
c-0.1,0.2-0.1,0.4,0,0.5c0.6,0.8,1.4,1.2,2.4,1.2h22c1.7,0,3-1.3,3-3v-13C30,8.6,29.6,7.7,28.8,7.1z"/>
</g>
<g>
<path d="M27,20V7c0-1.4-1.1-2.5-2.5-2.5h-22C1.1,4.5,0,5.6,0,7v13c0,1.4,1.1,2.5,2.5,2.5h22C25.9,22.5,27,21.4,27,20z M18.5,9.5h6
c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,9.5,18.5,9.5z M18.5,13.5h6c0.3,0,0.5,0.2,0.5,0.5
s-0.2,0.5-0.5,0.5h-6c-0.3,0-0.5-0.2-0.5-0.5S18.2,13.5,18.5,13.5z M9,20c-3.6,0-6.5-2.9-6.5-6.5S5.4,7,9,7s6.5,2.9,6.5,6.5
S12.6,20,9,20z M18,18c0-0.3,0.2-0.5,0.5-0.5h6c0.3,0,0.5,0.2,0.5,0.5s-0.2,0.5-0.5,0.5h-6C18.2,18.5,18,18.3,18,18z"/>
</g>
<g>
<path d="M10.9,14.8c0.7-0.6,1.1-1.4,1.1-2.3c0-1.7-1.3-3-3-3s-3,1.3-3,3c0,0.9,0.4,1.8,1.1,2.3c-1,0.3-1.9,1-2.7,1.8
C5.5,18,7.1,19,9,19s3.5-1,4.5-2.4C12.8,15.8,11.9,15.2,10.9,14.8z"/>
</g>
<path d="M10.9,14.8c0.7-0.6,1.1-1.4,1.1-2.3c0-1.7-1.3-3-3-3s-3,1.3-3,3c0,0.9,0.4,1.8,1.1,2.3c-1,0.3-1.9,1-2.7,1.8 C5.5,18,7.1,19,9,19s3.5-1,4.5-2.4C12.8,15.8,11.9,15.2,10.9,14.8z"/>
</g>
</svg>
</div>
</g>
</IconBase>
</template>

<style scoped>
<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

</style>
withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

+ 13
- 14
src/components/icons/Menu.vue Bestand weergeven

@@ -1,19 +1,18 @@
<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 30 30" style="fill:#ffffff;" xml:space="preserve">
<g>
<path d="M0.5,7h29C29.776,7,30,6.776,30,6.5S29.776,6,29.5,6h-29C0.224,6,0,6.224,0,6.5S0.224,7,0.5,7z"/>
<path d="M29.5,15h-29C0.224,15,0,15.224,0,15.5S0.224,16,0.5,16h29c0.276,0,0.5-0.224,0.5-0.5S29.776,15,29.5,15z"/>
<path d="M29.5,23h-29C0.224,23,0,23.224,0,23.5S0.224,24,0.5,24h29c0.276,0,0.5-0.224,0.5-0.5S29.776,23,29.5,23z"/>
</g>
</svg>
</div>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M0.5,7h29C29.776,7,30,6.776,30,6.5S29.776,6,29.5,6h-29C0.224,6,0,6.224,0,6.5S0.224,7,0.5,7z"/>
<path d="M29.5,15h-29C0.224,15,0,15.224,0,15.5S0.224,16,0.5,16h29c0.276,0,0.5-0.224,0.5-0.5S29.776,15,29.5,15z"/>
<path d="M29.5,23h-29C0.224,23,0,23.224,0,23.5S0.224,24,0.5,24h29c0.276,0,0.5-0.224,0.5-0.5S29.776,23,29.5,23z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
</script>

<style scoped>
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

</style>
withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

+ 16
- 0
src/components/icons/Plus.vue Bestand weergeven

@@ -0,0 +1,16 @@
<template>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M14,14V1c0-0.552,0.448-1,1-1s1,0.448,1,1v13h13c0.552,0,1,0.448,1,1s-0.448,1-1,1H16v13c0,0.552-0.448,1-1,1 s-1-0.448-1-1V16H1c-0.552,0-1-0.448-1-1s0.448-1,1-1H14z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

+ 12
- 19
src/components/icons/SpeechBubbles.vue Bestand weergeven

@@ -1,26 +1,19 @@
<template>
<div class="h-4 w-4 cursor-pointer mx-2">
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="speech-bubbles" x="0px" y="0px"
viewBox="0 0 30 30" style="fill:#ffffff" xml:space="preserve">
<g id="Speech_Bubbles">
<g>
<path d="M27.5,0h-21C5.122,0,4,1.122,4,2.5v15C4,18.879,5.122,20,6.5,20h12.708l5.941,5.856C25.244,25.95,25.371,26,25.5,26
c0.065,0,0.131-0.013,0.193-0.039C25.879,25.884,26,25.701,26,25.5V20h1.5c1.379,0,2.5-1.121,2.5-2.5v-15
C30,1.122,28.879,0,27.5,0z"/>
<path d="M6.5,21C4.57,21,3,19.43,3,17.5V4H2.5C1.121,4,0,5.122,0,6.5v15C0,22.879,1.121,24,2.5,24H4v5.5
c0,0.201,0.121,0.384,0.307,0.461C4.369,29.987,4.435,30,4.5,30c0.129,0,0.256-0.05,0.351-0.144L10.792,24h11.05l-3.044-3H6.5z"/>
</g>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g id="Speech_Bubbles">
<g>
<path d="M27.5,0h-21C5.122,0,4,1.122,4,2.5v15C4,18.879,5.122,20,6.5,20h12.708l5.941,5.856C25.244,25.95,25.371,26,25.5,26 c0.065,0,0.131-0.013,0.193-0.039C25.879,25.884,26,25.701,26,25.5V20h1.5c1.379,0,2.5-1.121,2.5-2.5v-15 C30,1.122,28.879,0,27.5,0z"/>
<path d="M6.5,21C4.57,21,3,19.43,3,17.5V4H2.5C1.121,4,0,5.122,0,6.5v15C0,22.879,1.121,24,2.5,24H4v5.5 c0,0.201,0.121,0.384,0.307,0.461C4.369,29.987,4.435,30,4.5,30c0.129,0,0.256-0.05,0.351-0.144L10.792,24h11.05l-3.044-3H6.5z"/>
</g>
</svg>
</div>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

<style scoped>
#speech-bubbles:hover {
fill: #2f92de !important;
}
</style>

+ 13
- 19
src/components/icons/Stack.vue Bestand weergeven

@@ -1,24 +1,18 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
:viewBox="`0 0 ${viewBox} ${viewBox}`" style="width: 20px; height: 20px;" xml:space="preserve" fill="white">
<path d="M0.271,10.444l14.5,7.5C14.842,17.981,14.921,18,15,18s0.158-0.019,0.229-0.056l14.5-7.5C29.896,10.358,30,10.187,30,10
s-0.104-0.358-0.271-0.444l-14.5-7.5c-0.144-0.074-0.315-0.074-0.459,0l-14.5,7.5C0.104,9.642,0,9.813,0,10
S0.104,10.358,0.271,10.444z"/>
<path d="M29.729,19.556l-2.886-1.493l-11.155,5.77C15.477,23.942,15.239,24,15,24s-0.477-0.058-0.689-0.167l-11.155-5.77
l-2.886,1.493C0.104,19.642,0,19.813,0,20s0.104,0.358,0.271,0.444l14.5,7.5C14.842,27.981,14.921,28,15,28
s0.158-0.019,0.229-0.056l14.5-7.5C29.896,20.358,30,20.187,30,20S29.896,19.642,29.729,19.556z"/>
<path d="M29.729,14.556l-2.886-1.493l-11.155,5.77C15.477,18.942,15.239,19,15,19s-0.477-0.058-0.689-0.167l-11.155-5.77
l-2.886,1.493C0.104,14.642,0,14.813,0,15s0.104,0.358,0.271,0.444l14.5,7.5C14.842,22.981,14.921,23,15,23
s0.158-0.019,0.229-0.056l14.5-7.5C29.896,15.358,30,15.187,30,15S29.896,14.642,29.729,14.556z"/>
</svg>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g>
<path d="M0.271,10.444l14.5,7.5C14.842,17.981,14.921,18,15,18s0.158-0.019,0.229-0.056l14.5-7.5C29.896,10.358,30,10.187,30,10 s-0.104-0.358-0.271-0.444l-14.5-7.5c-0.144-0.074-0.315-0.074-0.459,0l-14.5,7.5C0.104,9.642,0,9.813,0,10 S0.104,10.358,0.271,10.444z"/>
<path d="M29.729,19.556l-2.886-1.493l-11.155,5.77C15.477,23.942,15.239,24,15,24s-0.477-0.058-0.689-0.167l-11.155-5.77 l-2.886,1.493C0.104,19.642,0,19.813,0,20s0.104,0.358,0.271,0.444l14.5,7.5C14.842,27.981,14.921,28,15,28 s0.158-0.019,0.229-0.056l14.5-7.5C29.896,20.358,30,20.187,30,20S29.896,19.642,29.729,19.556z"/>
<path d="M29.729,14.556l-2.886-1.493l-11.155,5.77C15.477,18.942,15.239,19,15,19s-0.477-0.058-0.689-0.167l-11.155-5.77 l-2.886,1.493C0.104,14.642,0,14.813,0,15s0.104,0.358,0.271,0.444l14.5,7.5C14.842,22.981,14.921,23,15,23 s0.158-0.019,0.229-0.056l14.5-7.5C29.896,15.358,30,15.187,30,15S29.896,14.642,29.729,14.556z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
const props = defineProps({
viewBox: {type: Number, default: 90},
})
</script>

<style scoped>
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

</style>
withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

+ 10
- 15
src/components/icons/ThumbsDown.vue Bestand weergeven

@@ -1,21 +1,16 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" xml:space="preserve">
<g id="Thumbs_Down">
<path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498
c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023
C9.243,1.847,8.121,1,6.805,1H2.5C1.121,1,0,2.121,0,3.5v13C0,17.879,1.121,19,2.5,19h5c1.379,0,2.5-1.121,2.5-2.5v-0.315
l0.357,0.182L14,24.305v4.478c0,0.133,0.053,0.261,0.147,0.354C14.184,29.172,15.031,30,16.326,30c1.5,0,3.587-2.52,3.587-5
c0-1.777-0.663-3.922-1.042-5h7.957c1.566,0,3.014-1.334,3.159-2.913c0.086-0.938-0.258-1.906-0.871-2.563
C29.673,14.018,30,13.299,30,12.524z"/>
</g>
</svg>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g id="Thumbs_Down">
<path d="M30,12.524c0-1.094-0.653-2.049-1.61-2.472c0.227-0.404,0.35-0.864,0.35-1.335c0-1.108-0.656-2.073-1.615-2.498 c0.448-0.812,0.537-1.916-0.1-2.924C26.519,2.496,25.575,2,24.562,2H13.108c-0.949,0-2.44,0.42-3.451,1.023 C9.243,1.847,8.121,1,6.805,1H2.5C1.121,1,0,2.121,0,3.5v13C0,17.879,1.121,19,2.5,19h5c1.379,0,2.5-1.121,2.5-2.5v-0.315 l0.357,0.182L14,24.305v4.478c0,0.133,0.053,0.261,0.147,0.354C14.184,29.172,15.031,30,16.326,30c1.5,0,3.587-2.52,3.587-5 c0-1.777-0.663-3.922-1.042-5h7.957c1.566,0,3.014-1.334,3.159-2.913c0.086-0.938-0.258-1.906-0.871-2.563 C29.673,14.018,30,13.299,30,12.524z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

<style scoped>

</style>

+ 10
- 15
src/components/icons/ThumbsUp.vue Bestand weergeven

@@ -1,21 +1,16 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" xml:space="preserve">
<g id="Thumbs_Up">
<path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11
h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218
v4.478l-3.643,7.938L10,14.815V14.5c0-1.379-1.121-2.5-2.5-2.5h-5C1.121,12,0,13.121,0,14.5v13C0,28.879,1.121,30,2.5,30h4.305
c1.316,0,2.438-0.847,2.853-2.023C10.668,28.58,12.159,29,13.108,29h11.453c1.014,0,1.957-0.496,2.463-1.296
c0.637-1.008,0.548-2.112,0.1-2.924c0.959-0.425,1.615-1.39,1.615-2.498c0-0.471-0.123-0.931-0.35-1.335
C29.347,20.524,30,19.569,30,18.476z"/>
</g>
</svg>
<IconBase :view-box="viewBox" :size="size" :title="title" :aria-hidden="ariaHidden" :role="role">
<g id="Thumbs_Up">
<path d="M30,18.476c0-0.774-0.327-1.493-0.884-1.999c0.613-0.657,0.957-1.626,0.871-2.563C29.842,12.334,28.395,11,26.828,11 h-7.957c0.379-1.078,1.042-3.223,1.042-5c0-2.48-2.087-5-3.587-5c-1.295,0-2.143,0.828-2.179,0.863C14.053,1.957,14,2.085,14,2.218 v4.478l-3.643,7.938L10,14.815V14.5c0-1.379-1.121-2.5-2.5-2.5h-5C1.121,12,0,13.121,0,14.5v13C0,28.879,1.121,30,2.5,30h4.305 c1.316,0,2.438-0.847,2.853-2.023C10.668,28.58,12.159,29,13.108,29h11.453c1.014,0,1.957-0.496,2.463-1.296 c0.637-1.008,0.548-2.112,0.1-2.924c0.959-0.425,1.615-1.39,1.615-2.498c0-0.471-0.123-0.931-0.35-1.335 C29.347,20.524,30,19.569,30,18.476z"/>
</g>
</IconBase>
</template>

<script setup lang="ts">
import IconBase from './IconBase.vue';
import type { IconBaseProps } from './types';

withDefaults(defineProps<IconBaseProps>(), {
viewBox: '0 0 30 30',
});
</script>

<style scoped>

</style>

+ 45
- 0
src/components/icons/index.ts Bestand weergeven

@@ -0,0 +1,45 @@
export * from './types';
export { default as IconBase } from './IconBase.vue';
export { default as AppIcon } from './AppIcon.vue';

export { default as Abstain } from './Abstain.vue';
export { default as IconAbstain } from './Abstain.vue';

export { default as Arrow } from './Arrow.vue';
export { default as IconArrow } from './Arrow.vue';

export { default as Cancel } from './Cancel.vue';
export { default as IconCancel } from './Cancel.vue';

export { default as Confirm } from './Confirm.vue';
export { default as IconConfirm } from './Confirm.vue';

export { default as DownArrow } from './DownArrow.vue';
export { default as IconDownArrow } from './DownArrow.vue';

export { default as Download } from './Download.vue';
export { default as IconDownload } from './Download.vue';

export { default as Filter } from './Filter.vue';
export { default as IconFilter } from './Filter.vue';

export { default as Identification } from './Identification.vue';
export { default as IconIdentification } from './Identification.vue';

export { default as Menu } from './Menu.vue';
export { default as IconMenu } from './Menu.vue';

export { default as Plus } from './Plus.vue';
export { default as IconPlus } from './Plus.vue';

export { default as SpeechBubbles } from './SpeechBubbles.vue';
export { default as IconSpeechBubbles } from './SpeechBubbles.vue';

export { default as Stack } from './Stack.vue';
export { default as IconStack } from './Stack.vue';

export { default as ThumbsDown } from './ThumbsDown.vue';
export { default as IconThumbsDown } from './ThumbsDown.vue';

export { default as ThumbsUp } from './ThumbsUp.vue';
export { default as IconThumbsUp } from './ThumbsUp.vue';

+ 25
- 0
src/components/icons/types.ts Bestand weergeven

@@ -0,0 +1,25 @@
export type IconName =
| 'abstain'
| 'arrow'
| 'cancel'
| 'confirm'
| 'down-arrow'
| 'download'
| 'filter'
| 'identification'
| 'menu'
| 'plus'
| 'speech-bubbles'
| 'stack'
| 'thumbs-down'
| 'thumbs-up';

export type IconSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number | string;

export interface IconBaseProps {
size?: IconSize;
viewBox?: string;
title?: string;
ariaHidden?: boolean;
role?: string;
}

+ 3
- 3
src/components/modals/AmendmentModal.vue Bestand weergeven

@@ -1,6 +1,6 @@
<template>
<Modal header="Amendment" button-text="Close">
<div v-for="item in amendmentContent" :key="item.key">
<Modal header="Amendment" button-text="Close" model-value>
<template v-for="item in amendmentContent" :key="item.key">
<template v-if="item.format === 'heading'">
<h3 class="text-xl font-bold">{{ item.value }}</h3>
</template>
@@ -19,7 +19,7 @@
<template v-else>
<p class="text-base whitespace-pre-wrap pt-2">{{ item.value }}</p>
</template>
</div>
</template>
</Modal>
</template>



+ 2
- 2
src/components/modals/FilterModal.vue Bestand weergeven

@@ -1,5 +1,5 @@
<template>
<Modal header="Filter" @on-closed="submit">
<Modal header="Filter" @on-closed="submit" model-value>
<slot>
<div class="flex flex-row justify-between my-3">
<div class="flex flex-col justify-center">
@@ -20,7 +20,7 @@
<script setup lang="ts" generic="T extends ContentModal">

import Modal from "./Modal.vue";
import { ContentModal } from "../../stores/modal.ts";
import { ContentModal } from "../../composables/useModal.ts";
import { useFilterNotifier } from "../../composables/useFilterNotifier.ts";
import { eventBus } from "../../utils/eventBus.ts";



+ 2
- 1
src/components/modals/Modal.vue Bestand weergeven

@@ -92,7 +92,7 @@ const emit = defineEmits<{
submit: [];
}>();

const {hide} = useModal();
const {hide, clearContent} = useModal();

const modalRef = ref<HTMLElement | null>(null);
const closeButtonRef = ref<HTMLButtonElement | null>(null);
@@ -100,6 +100,7 @@ const closeButtonRef = ref<HTMLButtonElement | null>(null);
const isOpen = computed(() => props.modelValue);

function close() {
clearContent();
emit("update:modelValue", false);
hide();
}


+ 118
- 0
src/components/modals/NewAmendmentModal.vue Bestand weergeven

@@ -0,0 +1,118 @@
<template>
<Modal
v-model="isOpen"
header="New Amendment"
:button-text="isSubmitting ? 'Submitting...' : 'Submit Amendment'"
:submit-disabled="isSubmitDisabled"
@submit="submit"
>
<div class="space-y-5">
<div v-if="errorMessage" class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">
{{ errorMessage }}
</div>

<div>
<label for="amendment-name" class="mb-1.5 block text-sm font-medium text-slate-700">
Amendment Title
</label>
<input
id="amendment-name"
v-model="name"
type="text"
placeholder="e.g., Update quorum percentage"
class="w-full rounded-lg border border-slate-300 px-3.5 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:border-amber-500 focus:outline-none focus:ring-4 focus:ring-amber-500/10"
/>
</div>

<div>
<label class="mb-1.5 block text-sm font-medium text-slate-700">
Amendment Content
</label>
<PostBuilder @update:content="assignContent" />
</div>
</div>
</Modal>
</template>

<script setup lang="ts">
import { computed, ref } from "vue";
import { useRoute } from "vue-router";
import * as freighter from "@stellar/freighter-api";
import Modal from "./Modal.vue";
import PostBuilder from "../PostBuilder.vue";
import { AmendmentService } from "../../generated/typescript";
import { useModal } from "../../composables/useModal.ts";
import { eventBus } from "../../utils/eventBus.ts";
import type { ProposalParagraph } from "../../types/proposal.ts";

const modal = useModal();
const route = useRoute();

const isOpen = ref(modal.getVisibility());
const name = ref("");
const content = ref([] as string[]);
const isSubmitting = ref(false);
const errorMessage = ref<string | null>(null);

const isSubmitDisabled = computed(() => {
return !name.value.trim() || !content.value.map(p => p.trim()).length || isSubmitting.value;
});

const assignContent = (paragraphs: ProposalParagraph[]) => {
content.value = paragraphs.map((p) => p.paragraphText);
};

const getProposalId = (): number | undefined => {
const modalPropId = modal.getContent("proposalId")?.value;
if (modalPropId !== undefined && !isNaN(Number(modalPropId))) {
return Number(modalPropId);
}
if (route.params.id && !isNaN(Number(route.params.id))) {
return Number(route.params.id);
}
return undefined;
};

const submit = async () => {
if (isSubmitDisabled.value) return;

const proposalId = getProposalId();
if (proposalId === undefined) {
errorMessage.value = "Proposal ID not found.";
return;
}

isSubmitting.value = true;
errorMessage.value = null;

try {
const isAllowed = await freighter.isAllowed();
if (!isAllowed) {
await freighter.setAllowed();
}

const addr = await freighter.getAddress();
if (!addr || !addr.address) {
throw new Error("Unable to retrieve wallet address from Freighter.");
}

await AmendmentService.addAmendment({
proposalId,
creator: addr.address,
name: name.value.trim(),
paragraphs: content.value.map(p => p.trim()),
});

eventBus.emit("amendmentCreated", proposalId);
modal.hide();
} catch (err) {
console.error("Failed to add amendment:", err);
errorMessage.value = err instanceof Error ? err.message : "Failed to submit amendment.";
} finally {
isSubmitting.value = false;
}
};
</script>

<style scoped>
</style>

+ 1
- 1
src/components/modals/ProposalModal.vue Bestand weergeven

@@ -58,7 +58,7 @@ const description = ref<ProposalParagraph[]>([]);
const submit = async () => {
if (await freighter.isAllowed()) {
const addr = await freighter.getAddress();
await ProposalService.createProposal({creator: addr.address, name: title.value, paragraphs: description.value});
await ProposalService.addProposal({creator: addr.address, name: title.value, paragraphs: description.value});
}
}



+ 4
- 25
src/components/modals/RegistrationModal.vue Bestand weergeven

@@ -1,11 +1,11 @@
<template>
<Modal header="Register">
<Modal header="Register" model-value>
<slot>
<div class="my-3">
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Username" v-model="username" />
</div>
<div class="my-3">
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Telegram Username" v-model="telegramHandle" />
<input type="text" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Full name" v-model="fullName" />
</div>
<div class="my-3">
<input type="password" class="w-full border-gray-300 border border-solid py-2 px-2.5" placeholder="Password" v-model="password" />
@@ -16,30 +16,9 @@

<script setup lang="ts">
import Modal from "./Modal.vue";
import { computed, ref } from "vue";
import { ref } from "vue";

const trimAtSymbol = (str: string, symbol: string) => {
const trimmed = ref(str);
while (trimmed.value.startsWith(symbol)) {
trimmed.value = trimmed.value.substring(1);
}

return trimmed.value;
}

const [username, tgUsername, password] = [ref(''), ref(''), ref('')];

const telegramHandle = computed({
get: () => tgUsername.value,
set: (value: string) => {
const handle = trimAtSymbol(value.trim(), '@');
if (handle.length > 0) {
tgUsername.value = `@${ handle }`
} else {
tgUsername.value = '';
}
}
});
const [username, fullName, password] = [ref(''), ref(''), ref('')];
</script>

<style scoped>


+ 141
- 5
src/composables/useSession.ts Bestand weergeven

@@ -1,9 +1,145 @@
import { computed, ref } from 'vue';
import freighter from '@stellar/freighter-api';
import { truncateWallet } from '../utils/wallet.ts';
import { getAuthToken, getUserIdFromJwt, parseJwt } from '../utils/auth.ts';
import { AuthService } from '../generated/typescript';

export const useSession = () => {
const hasFreighter = async () => {
return freighter.isConnected();
const walletAddress = ref<string | null>(null);
const authToken = ref<string | null>(null);
const userId = ref<number | undefined>(undefined);
const isConnecting = ref<boolean>(false);
const isInitialized = ref<boolean>(false);

const isAuthenticated = computed(() => !!authToken.value && !!walletAddress.value);
const truncatedAddress = computed(() => (walletAddress.value ? truncateWallet(walletAddress.value) : ''));

const hasFreighter = async (): Promise<boolean> => {
try {
if (typeof window === 'undefined' || !window.location) {
return false;
}
const res = await freighter.isConnected();
if (typeof res === 'boolean') {
return res;
}
return Boolean(res?.isConnected && !res?.error);
} catch (_) {
return false;
}
};

const disconnectWallet = (): void => {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('authToken');
localStorage.removeItem('token');
localStorage.removeItem('walletAddress');
}
} catch (_) {}

authToken.value = null;
walletAddress.value = null;
userId.value = undefined;
};

const connectWallet = async (): Promise<boolean> => {
isConnecting.value = true;
try {
await freighter.setAllowed();
const addr = await freighter.getAddress();
if (!addr?.address) {
return false;
}

const jwt = await AuthService.loginFreighter({
stellarAddress: addr.address,
});

if (jwt?.token) {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('authToken', jwt.token as string);
localStorage.setItem('walletAddress', addr.address);
}
} catch (_) {}

authToken.value = jwt.token as string;
walletAddress.value = addr.address;
userId.value = getUserIdFromJwt(jwt.token as string);
return true;
}

return false;
} catch (e) {
console.error('Failed to connect or authenticate with Freighter:', e);
return false;
} finally {
isConnecting.value = false;
}
};

const initSession = async (): Promise<void> => {
try {
const token = getAuthToken();
const storedAddress = typeof localStorage !== 'undefined' ? localStorage.getItem('walletAddress') : null;

if (!token) {
authToken.value = null;
walletAddress.value = null;
userId.value = undefined;
return;
}

const payload = parseJwt(token);
if (!payload || (payload.exp && Date.now() >= payload.exp * 1000)) {
disconnectWallet();
return;
}

return { hasFreighter };
}
authToken.value = token;
userId.value = getUserIdFromJwt(token);
walletAddress.value = payload.stellarAddress || storedAddress || null;

try {
const connected = await hasFreighter();
if (connected) {
const allowedRes = await freighter.isAllowed();
const allowed = typeof allowedRes === 'boolean' ? allowedRes : Boolean(allowedRes?.isAllowed && !allowedRes?.error);
if (allowed) {
const addrRes = await freighter.getAddress();
if (addrRes?.address) {
walletAddress.value = addrRes.address;
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('walletAddress', addrRes.address);
}
} catch (_) {}
}
}
}
} catch (e) {
console.warn('Freighter verification during initSession failed:', e);
}
} catch (e) {
console.error('Error during initSession:', e);
disconnectWallet();
} finally {
isInitialized.value = true;
}
};

export const useSession = () => {
return {
walletAddress,
authToken,
userId,
isAuthenticated,
isConnecting,
isInitialized,
truncatedAddress,
hasFreighter,
connectWallet,
disconnectWallet,
initSession,
};
};

+ 24
- 0
src/generated/typescript/index.ts Bestand weergeven

@@ -8,17 +8,41 @@ export { OpenAPI } from './core/OpenAPI';
export type { OpenAPIConfig } from './core/OpenAPI';

export type { AddAmendmentRequest } from './models/AddAmendmentRequest';
export type { AddCommentRequest } from './models/AddCommentRequest';
export type { AddProposalRequest } from './models/AddProposalRequest';
export type { Amendment } from './models/Amendment';
export type { AmendmentParagraph } from './models/AmendmentParagraph';
export { AmendmentStatus } from './models/AmendmentStatus';
export type { AmendmentVote } from './models/AmendmentVote';
export type { AmendmentWithParagraphs } from './models/AmendmentWithParagraphs';
export type { AmendmentWithSummary } from './models/AmendmentWithSummary';
export type { Claims } from './models/Claims';
export type { CommentWithUser } from './models/CommentWithUser';
export type { FreighterLoginRequest } from './models/FreighterLoginRequest';
export type { FreighterLoginResponse } from './models/FreighterLoginResponse';
export type { LoginRequest } from './models/LoginRequest';
export type { Organization } from './models/Organization';
export type { Proposal } from './models/Proposal';
export type { ProposalParagraph } from './models/ProposalParagraph';
export type { ProposalParagraphRequest } from './models/ProposalParagraphRequest';
export type { ProposalVote } from './models/ProposalVote';
export type { ProposalWithParagraphs } from './models/ProposalWithParagraphs';
export type { ProposalWithSummary } from './models/ProposalWithSummary';
export type { RegisteredUser } from './models/RegisteredUser';
export type { RegisterOrganizationRequest } from './models/RegisterOrganizationRequest';
export type { RegisterRequest } from './models/RegisterRequest';
export type { SelectableAmendment } from './models/SelectableAmendment';
export type { SelectableComment } from './models/SelectableComment';
export type { SelectableProposal } from './models/SelectableProposal';
export type { UserAmendmentVote } from './models/UserAmendmentVote';
export type { UserProposalVotes } from './models/UserProposalVotes';
export type { VoteAmendmentRequest } from './models/VoteAmendmentRequest';
export type { VoteAmendmentResponse } from './models/VoteAmendmentResponse';
export { VoteChoice } from './models/VoteChoice';
export type { VoteProposalRequest } from './models/VoteProposalRequest';

export { AmendmentService } from './services/AmendmentService';
export { AuthService } from './services/AuthService';
export { CommentService } from './services/CommentService';
export { OrganizationService } from './services/OrganizationService';
export { ProposalService } from './services/ProposalService';

+ 1
- 1
src/generated/typescript/models/AddAmendmentRequest.ts Bestand weergeven

@@ -3,9 +3,9 @@
/* tslint:disable */
/* eslint-disable */
export type AddAmendmentRequest = {
content: string;
creator: string;
name: string;
paragraphs: Array<string>;
proposalId: number;
};


+ 14
- 0
src/generated/typescript/models/AddCommentRequest.ts Bestand weergeven

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type AddCommentRequest = {
content: string;
/**
* Id of the comment being replied to, if any. Must be on the same proposal.
*/
parentId?: number | null;
proposalId: number;
userId: number;
};


+ 15
- 0
src/generated/typescript/models/Amendment.ts Bestand weergeven

@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentStatus } from './AmendmentStatus';
export type Amendment = {
createdAt: string;
creator: string;
isCurrent: boolean;
name: string;
proposalId: number;
status: AmendmentStatus;
updatedAt: string;
};


+ 11
- 0
src/generated/typescript/models/AmendmentParagraph.ts Bestand weergeven

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type AmendmentParagraph = {
amendmentId: number;
id: number;
index: number;
paragraphText: string;
};


+ 10
- 0
src/generated/typescript/models/AmendmentStatus.ts Bestand weergeven

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum AmendmentStatus {
PROPOSED = 'Proposed',
APPROVED = 'Approved',
WITHDRAWN = 'Withdrawn',
REJECTED = 'Rejected',
}

+ 12
- 0
src/generated/typescript/models/AmendmentVote.ts Bestand weergeven

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type AmendmentVote = {
amendmentId: number;
createdAt: string;
id: number;
inFavor: boolean;
userId: number;
};


+ 18
- 0
src/generated/typescript/models/AmendmentWithParagraphs.ts Bestand weergeven

@@ -0,0 +1,18 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentParagraph } from './AmendmentParagraph';
import type { AmendmentStatus } from './AmendmentStatus';
export type AmendmentWithParagraphs = {
created_at: string;
creator: string;
id: number;
is_current: boolean;
name: string;
paragraphs: Array<AmendmentParagraph>;
proposal_id: number;
status: AmendmentStatus;
updated_at: string;
};


+ 17
- 0
src/generated/typescript/models/AmendmentWithSummary.ts Bestand weergeven

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentStatus } from './AmendmentStatus';
export type AmendmentWithSummary = {
created_at: string;
creator: string;
id: number;
is_current: boolean;
name: string;
proposal_id: number;
status: AmendmentStatus;
summary?: string | null;
updated_at: string;
};


+ 14
- 0
src/generated/typescript/models/Claims.ts Bestand weergeven

@@ -0,0 +1,14 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Claims = {
exp: number;
fullName: string;
iat: number;
organizationId: number;
stellarAddress: string;
userId: number;
username: string;
};


+ 17
- 0
src/generated/typescript/models/CommentWithUser.ts Bestand weergeven

@@ -0,0 +1,17 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type CommentWithUser = {
content?: string | null;
createdAt: string;
fullName?: string | null;
id: number;
isCurrent: boolean;
parentId?: number | null;
proposalId: number;
updatedAt: string;
userId?: number | null;
username?: string | null;
};


+ 8
- 0
src/generated/typescript/models/FreighterLoginResponse.ts Bestand weergeven

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type FreighterLoginResponse = {
token: string;
};


+ 11
- 0
src/generated/typescript/models/Organization.ts Bestand weergeven

@@ -0,0 +1,11 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Organization = {
createdAt: string;
id: number;
name: string;
updatedAt: string;
};


+ 1
- 0
src/generated/typescript/models/Proposal.ts Bestand weergeven

@@ -6,6 +6,7 @@ export type Proposal = {
createdAt: string;
creator: string;
name: string;
organizationId: number;
updatedAt: string;
};


+ 13
- 0
src/generated/typescript/models/ProposalVote.ts Bestand weergeven

@@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { VoteChoice } from './VoteChoice';
export type ProposalVote = {
choice: VoteChoice;
createdAt: string;
id: number;
proposalId: number;
userId: number;
};


+ 8
- 0
src/generated/typescript/models/RegisterOrganizationRequest.ts Bestand weergeven

@@ -0,0 +1,8 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RegisterOrganizationRequest = {
name: string;
};


+ 5
- 1
src/generated/typescript/models/RegisterRequest.ts Bestand weergeven

@@ -4,8 +4,12 @@
/* eslint-disable */
export type RegisterRequest = {
email?: string | null;
fullName?: string | null;
/**
* The organization the user is joining; must already be registered
*/
organizationId: number;
password: string;
stellarAddress: string;
username: string;
};


+ 12
- 0
src/generated/typescript/models/RegisteredUser.ts Bestand weergeven

@@ -0,0 +1,12 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type RegisteredUser = {
email?: string | null;
id: number;
organization_id: number;
stellar_address: string;
username: string;
};


+ 16
- 0
src/generated/typescript/models/SelectableAmendment.ts Bestand weergeven

@@ -0,0 +1,16 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentStatus } from './AmendmentStatus';
export type SelectableAmendment = {
createdAt: string;
creator: string;
id: number;
isCurrent: boolean;
name: string;
proposalId: number;
status: AmendmentStatus;
updatedAt: string;
};


+ 15
- 0
src/generated/typescript/models/SelectableComment.ts Bestand weergeven

@@ -0,0 +1,15 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SelectableComment = {
content?: string | null;
createdAt: string;
id: number;
isCurrent: boolean;
parentId?: number | null;
proposalId: number;
updatedAt: string;
userId?: number | null;
};


+ 1
- 0
src/generated/typescript/models/SelectableProposal.ts Bestand weergeven

@@ -7,6 +7,7 @@ export type SelectableProposal = {
creator: string;
id: number;
name: string;
organizationId: number;
updatedAt?: string | null;
};


+ 13
- 0
src/generated/typescript/models/UserAmendmentVote.ts Bestand weergeven

@@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentVote } from './AmendmentVote';
/**
* A user's vote (if any) on a single amendment of a proposal
*/
export type UserAmendmentVote = {
amendmentId: number;
vote?: (AmendmentVote | null);
};


+ 19
- 0
src/generated/typescript/models/UserProposalVotes.ts Bestand weergeven

@@ -0,0 +1,19 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { ProposalVote } from './ProposalVote';
import type { UserAmendmentVote } from './UserAmendmentVote';
/**
* All of a user's votes on a proposal and its amendments
*/
export type UserProposalVotes = {
/**
* One entry per amendment on the proposal, ordered by amendment id
*/
amendmentVotes: Array<UserAmendmentVote>;
proposalId: number;
proposalVote?: (ProposalVote | null);
userId: number;
};


+ 9
- 0
src/generated/typescript/models/VoteAmendmentRequest.ts Bestand weergeven

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type VoteAmendmentRequest = {
amendmentId: number;
inFavor: boolean;
};


+ 13
- 0
src/generated/typescript/models/VoteAmendmentResponse.ts Bestand weergeven

@@ -0,0 +1,13 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AmendmentVote } from './AmendmentVote';
export type VoteAmendmentResponse = {
/**
* False if the user had already voted on this amendment, in which case nothing was changed
*/
recorded: boolean;
vote?: (AmendmentVote | null);
};


+ 9
- 0
src/generated/typescript/models/VoteChoice.ts Bestand weergeven

@@ -0,0 +1,9 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum VoteChoice {
YES = 'yes',
NO = 'no',
ABSTAIN = 'abstain',
}

+ 10
- 0
src/generated/typescript/models/VoteProposalRequest.ts Bestand weergeven

@@ -0,0 +1,10 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { VoteChoice } from './VoteChoice';
export type VoteProposalRequest = {
choice: VoteChoice;
proposalId: number;
};


+ 48
- 6
src/generated/typescript/services/AmendmentService.ts Bestand weergeven

@@ -3,18 +3,23 @@
/* tslint:disable */
/* eslint-disable */
import type { AddAmendmentRequest } from '../models/AddAmendmentRequest';
import type { AmendmentWithParagraphs } from '../models/AmendmentWithParagraphs';
import type { AmendmentWithSummary } from '../models/AmendmentWithSummary';
import type { SelectableAmendment } from '../models/SelectableAmendment';
import type { VoteAmendmentRequest } from '../models/VoteAmendmentRequest';
import type { VoteAmendmentResponse } from '../models/VoteAmendmentResponse';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class AmendmentService {
/**
* @param requestBody
* @returns any CID of the added amendment
* @returns SelectableAmendment The new amendment
* @throws ApiError
*/
public static addAmendment(
requestBody: AddAmendmentRequest,
): CancelablePromise<any> {
): CancelablePromise<SelectableAmendment> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/amendment/add',
@@ -27,28 +32,65 @@ export class AmendmentService {
});
}
/**
* @returns any Amendment details
* @param amendmentId
* @returns AmendmentWithParagraphs Amendment details
* @throws ApiError
*/
public static getAmendment(): CancelablePromise<any> {
public static getAmendment(
amendmentId: number,
): CancelablePromise<AmendmentWithParagraphs> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/amendment/get',
query: {
'amendmentId': amendmentId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Amendment not found`,
500: `Internal server error`,
},
});
}
/**
* @param requestBody
* @returns VoteAmendmentResponse Vote result; `recorded` is false if the user already voted
* @throws ApiError
*/
public static voteAmendment(
requestBody: VoteAmendmentRequest,
): CancelablePromise<VoteAmendmentResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/amendment/vote',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Amendment not found in the current user's organization`,
500: `Internal server error`,
},
});
}
/**
* @returns any List of amendments for a proposal
* @param proposalId
* @returns AmendmentWithSummary List of amendments for a proposal
* @throws ApiError
*/
public static listAmendments(): CancelablePromise<any> {
public static listAmendments(
proposalId: number,
): CancelablePromise<Array<AmendmentWithSummary>> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/amendments/list',
query: {
'proposalId': proposalId,
},
errors: {
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
500: `Internal server error`,
},
});


+ 6
- 4
src/generated/typescript/services/AuthService.ts Bestand weergeven

@@ -3,7 +3,9 @@
/* tslint:disable */
/* eslint-disable */
import type { FreighterLoginRequest } from '../models/FreighterLoginRequest';
import type { FreighterLoginResponse } from '../models/FreighterLoginResponse';
import type { LoginRequest } from '../models/LoginRequest';
import type { RegisteredUser } from '../models/RegisteredUser';
import type { RegisterRequest } from '../models/RegisterRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
@@ -31,12 +33,12 @@ export class AuthService {
}
/**
* @param requestBody
* @returns any User logged in successfully
* @returns FreighterLoginResponse User logged in successfully
* @throws ApiError
*/
public static loginFreighter(
requestBody: FreighterLoginRequest,
): CancelablePromise<any> {
): CancelablePromise<FreighterLoginResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/login/freighter',
@@ -66,12 +68,12 @@ export class AuthService {
}
/**
* @param requestBody
* @returns any User registered successfully
* @returns RegisteredUser User registered successfully
* @throws ApiError
*/
public static register(
requestBody: RegisterRequest,
): CancelablePromise<any> {
): CancelablePromise<RegisteredUser> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/auth/register',


+ 74
- 0
src/generated/typescript/services/CommentService.ts Bestand weergeven

@@ -0,0 +1,74 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { AddCommentRequest } from '../models/AddCommentRequest';
import type { CommentWithUser } from '../models/CommentWithUser';
import type { SelectableComment } from '../models/SelectableComment';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class CommentService {
/**
* @param requestBody
* @returns SelectableComment The new comment
* @throws ApiError
*/
public static addComment(
requestBody: AddCommentRequest,
): CancelablePromise<SelectableComment> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/comment/add',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
500: `Internal server error`,
},
});
}
/**
* @param commentId
* @returns CommentWithUser Comment details
* @throws ApiError
*/
public static getComment(
commentId: number,
): CancelablePromise<CommentWithUser> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/comment/get',
query: {
'commentId': commentId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Comment not found`,
500: `Internal server error`,
},
});
}
/**
* @param proposalId
* @returns CommentWithUser List of comments for a proposal
* @throws ApiError
*/
public static listComments(
proposalId: number,
): CancelablePromise<Array<CommentWithUser>> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/comments/list',
query: {
'proposalId': proposalId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
500: `Internal server error`,
},
});
}
}

+ 52
- 0
src/generated/typescript/services/OrganizationService.ts Bestand weergeven

@@ -0,0 +1,52 @@
/* generated using openapi-typescript-codegen -- do not edit */
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Organization } from '../models/Organization';
import type { RegisterOrganizationRequest } from '../models/RegisterOrganizationRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
export class OrganizationService {
/**
* @param organizationId
* @returns Organization Organization details
* @throws ApiError
*/
public static getOrganization(
organizationId: number,
): CancelablePromise<Organization> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/organization/get',
query: {
'organizationId': organizationId,
},
errors: {
400: `Bad request`,
404: `Organization not found`,
500: `Internal server error`,
},
});
}
/**
* @param requestBody
* @returns Organization The new organization
* @throws ApiError
*/
public static registerOrganization(
requestBody: RegisterOrganizationRequest,
): CancelablePromise<Organization> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/organization/register',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
409: `An organization with this name already exists`,
500: `Internal server error`,
},
});
}
}

+ 53
- 1
src/generated/typescript/services/ProposalService.ts Bestand weergeven

@@ -3,7 +3,10 @@
/* tslint:disable */
/* eslint-disable */
import type { AddProposalRequest } from '../models/AddProposalRequest';
import type { ProposalVote } from '../models/ProposalVote';
import type { ProposalWithParagraphs } from '../models/ProposalWithParagraphs';
import type { UserProposalVotes } from '../models/UserProposalVotes';
import type { VoteProposalRequest } from '../models/VoteProposalRequest';
import type { CancelablePromise } from '../core/CancelablePromise';
import { OpenAPI } from '../core/OpenAPI';
import { request as __request } from '../core/request';
@@ -23,6 +26,7 @@ export class ProposalService {
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
500: `Failed to add proposal`,
},
});
@@ -42,6 +46,7 @@ export class ProposalService {
'propId': propId,
},
errors: {
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
},
});
@@ -62,14 +67,60 @@ export class ProposalService {
},
errors: {
400: `Invalid proposalId`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
500: `Failed to retrieve proposal`,
},
});
}
/**
* @param requestBody
* @returns ProposalVote The recorded vote
* @throws ApiError
*/
public static voteProposal(
requestBody: VoteProposalRequest,
): CancelablePromise<ProposalVote> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/proposal/vote',
body: requestBody,
mediaType: 'application/json',
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found in the current user's organization`,
409: `User has already voted on this proposal`,
500: `Internal server error`,
},
});
}
/**
* @param proposalId
* @returns UserProposalVotes The current user's vote on the proposal and on each of its amendments
* @throws ApiError
*/
public static getUserVotes(
proposalId: number,
): CancelablePromise<UserProposalVotes> {
return __request(OpenAPI, {
method: 'GET',
url: '/api/v1/proposal/votes',
query: {
'proposalId': proposalId,
},
errors: {
400: `Bad request`,
401: `Missing, invalid, or expired token`,
404: `Proposal not found`,
500: `Internal server error`,
},
});
}
/**
* @param offset
* @param limit
* @returns any Returns a list of proposals
* @returns any Returns a list of proposals in the current user's organization
* @throws ApiError
*/
public static listProposals(
@@ -84,6 +135,7 @@ export class ProposalService {
'limit': limit,
},
errors: {
401: `Missing, invalid, or expired token`,
500: `Internal server error`,
},
});


+ 2
- 2
src/main.ts Bestand weergeven

@@ -3,11 +3,11 @@ import './index.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import './index.css'
import { OpenAPI } from './generated/typescript'
import { getAuthToken } from './utils/auth'

OpenAPI.BASE = ''
OpenAPI.HEADERS = localStorage.getItem("token") ? {"Authorization": "Bearer " + localStorage.getItem("token")} : {};
OpenAPI.TOKEN = async () => getAuthToken() || ''

const pinia = createPinia();
createApp(App).use(pinia).use(router).mount('#app')

+ 0
- 30
src/types/amendment.ts Bestand weergeven

@@ -1,30 +0,0 @@
export interface Amendment {
id: number;
name: string;
cid: string;
summary?: string;
status: 'proposed' | 'approved' | 'withdrawn' | 'rejected';
creator: string;
isCurrent: boolean;
previousCid?: string;
createdAt: string;
updatedAt: string;
proposalId: number;
}

export interface ListAmendmentsResponse {
amendments: Amendment[];
}

export interface AmendmentFile {
name: string;
content: string;
creator: string;
createdAt: Date;
updatedAt: Date;
proposalId: number;
}

export interface GetAmendmentResponse {
amendment: AmendmentFile;
}

+ 38
- 0
src/utils/auth.ts Bestand weergeven

@@ -0,0 +1,38 @@
import {Claims} from "../generated/typescript";

export function parseJwt(token: string): Claims | null {
try {
const base64Url = token.split('.')[1];
if (!base64Url) return null;
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64)
.split('')
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
);
return JSON.parse(jsonPayload);
} catch {
return null;
}
}

export function getAuthToken(): string | null {
try {
return localStorage.getItem('authToken') || localStorage.getItem('token');
} catch {
return null;
}
}

export function getUserIdFromJwt(): number | undefined;
export function getUserIdFromJwt(jwt?: string): number | undefined;
export function getUserIdFromJwt(jwt?: string): number | undefined {
const token = jwt ?? getAuthToken();
if (!token) return undefined;
const payload = parseJwt(token);
if (!payload) return undefined;
const rawId = payload.userId;
const parsedId = Number(rawId);
return !isNaN(parsedId) && parsedId > 0 ? parsedId : undefined;
}

+ 35
- 0
src/utils/comments.ts Bestand weergeven

@@ -0,0 +1,35 @@
import {CommentWithUser} from "../generated/typescript";

export interface CommentNode {
comment: CommentWithUser;
replies: CommentNode[];
}

/**
* Builds a reply tree from a flat comment list. Comments with a null `parentId`
* are top-level; comments whose parent is missing from the list are also treated
* as top-level so they are never silently dropped.
*/
export function buildCommentTree(comments: CommentWithUser[]): CommentNode[] {
const nodes = new Map<number, CommentNode>();
for (const comment of comments) {
nodes.set(comment.id, {comment, replies: []});
}

const roots: CommentNode[] = [];
for (const comment of comments) {
const node = nodes.get(comment.id)!;
const parent = comment.parentId != null ? nodes.get(comment.parentId) : undefined;
if (parent && parent !== node) parent.replies.push(node);
else roots.push(node);
}

// Replies read top-to-bottom as a conversation, oldest first.
const byCreatedAt = (a: CommentNode, b: CommentNode) =>
new Date(a.comment.createdAt).getTime() - new Date(b.comment.createdAt).getTime();
for (const node of nodes.values()) {
node.replies.sort(byCreatedAt);
}

return roots;
}

+ 1
- 8
src/utils/eventBus.ts Bestand weergeven

@@ -1,11 +1,4 @@
import { useFilterStore } from "../stores/filterStore.ts";
import { Ref } from "vue";

type RefState<T> = {
[K in keyof T]: Ref<T[K]>
}
type FilterStoreState = ReturnType<typeof useFilterStore>['$state'];
type Callback = (...args: RefState<FilterStoreState>[]) => void
type Callback = (...args: any[]) => void;

class EventBus {
private events: { [key: string]: Callback[] } = {}


+ 4
- 4
src/views/HomeView.vue Bestand weergeven

@@ -1,11 +1,11 @@
<template>
<div class="home h-screen pb-12 md:pb-0">
<div class="flex h-full">
<div class="flex">
<!-- Sidebar for md+ screens (always visible) -->
<Sidebar
v-show="width > 768 || isSidebarOpen"
:proposals="proposals"
class="fixed md:static w-64 h-full overflow-y-auto border-r md:flex-shrink-0 inset-y-0 left-0 z-30 transform transition-transform duration-300 ease-in-out"
class="sticky sm:w-1/4 h-full overflow-y-auto border-r md:flex-shrink-0 inset-y-0 left-0 top-0 z-30 transform transition-transform duration-300 ease-in-out"
/>

<!-- Overlay for small screens when sidebar is open -->
@@ -23,11 +23,11 @@

<!-- Toggle button for small screens -->
<button
class="md:hidden fixed w-full z-40 bottom-0 left-0 bg-gray-500 px-3 py-3 text-white font-semibold"
class="md:hidden fixed w-full z-40 bottom-0 left-0 bg-gray-500 px-3 py-3 text-white font-semibold flex items-center justify-center gap-2 cursor-pointer"
@click="isSidebarOpen = !isSidebarOpen"
aria-label="Toggle sidebar"
>
<Stack :viewBox="36" style="display: inline-block" />
<Stack class="size-5" />
View Proposals
</button>
</div>


+ 1
- 1
src/views/ProposalView.vue Bestand weergeven

@@ -1,5 +1,5 @@
<template>
<div class="h-full">
<div>
<LoadingSpinner
v-if="isLoading('content')"
identifier="content"


+ 1
- 1
tsconfig.app.tsbuildinfo Bestand weergeven

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/composables/useFilterNotifier.ts","./src/composables/useLoading.ts","./src/composables/useModal.ts","./src/composables/useSession.ts","./src/generated/typescript/index.ts","./src/generated/typescript/core/ApiError.ts","./src/generated/typescript/core/ApiRequestOptions.ts","./src/generated/typescript/core/ApiResult.ts","./src/generated/typescript/core/CancelablePromise.ts","./src/generated/typescript/core/OpenAPI.ts","./src/generated/typescript/core/request.ts","./src/generated/typescript/models/AddAmendmentRequest.ts","./src/generated/typescript/models/AddProposalRequest.ts","./src/generated/typescript/models/FreighterLoginRequest.ts","./src/generated/typescript/models/LoginRequest.ts","./src/generated/typescript/models/Proposal.ts","./src/generated/typescript/models/ProposalParagraph.ts","./src/generated/typescript/models/ProposalParagraphRequest.ts","./src/generated/typescript/models/ProposalWithParagraphs.ts","./src/generated/typescript/models/ProposalWithSummary.ts","./src/generated/typescript/models/RegisterRequest.ts","./src/generated/typescript/models/SelectableProposal.ts","./src/generated/typescript/schemas/$AddProposalRequest.ts","./src/generated/typescript/schemas/$FreighterLoginRequest.ts","./src/generated/typescript/schemas/$ListProposalsResponse.ts","./src/generated/typescript/schemas/$LoginRequest.ts","./src/generated/typescript/schemas/$ProposalItem.ts","./src/generated/typescript/schemas/$ProposalList.ts","./src/generated/typescript/schemas/$RegisterRequest.ts","./src/generated/typescript/schemas/$VisitProposalRequest.ts","./src/generated/typescript/services/AmendmentService.ts","./src/generated/typescript/services/AuthService.ts","./src/generated/typescript/services/ProposalService.ts","./src/router/index.ts","./src/stores/filterStore.ts","./src/types/amendment.ts","./src/types/color.ts","./src/types/comment.ts","./src/types/dropdown.ts","./src/types/headerletter.ts","./src/types/proposal.ts","./src/types/user.ts","./src/types/vote.ts","./src/utils/amendment.ts","./src/utils/eventBus.ts","./src/utils/markdown.ts","./src/utils/proposalExport.ts","./src/utils/wallet.ts","./src/App.vue","./src/components/AmendmentCarousel.vue","./src/components/BrandBar.vue","./src/components/Chip.vue","./src/components/ColoredHeader.vue","./src/components/Dropdown.vue","./src/components/LoadingSpinner.vue","./src/components/PostBuilder.vue","./src/components/ProposalContainer.vue","./src/components/ProposalDetail.vue","./src/components/ProposalEmptyState.vue","./src/components/ProposalExportCard.vue","./src/components/ProposalVotingCard.vue","./src/components/Sidebar.vue","./src/components/Thread.vue","./src/components/VotingControls.vue","./src/components/icons/Abstain.vue","./src/components/icons/Arrow.vue","./src/components/icons/Cancel.vue","./src/components/icons/Confirm.vue","./src/components/icons/DownArrow.vue","./src/components/icons/Download.vue","./src/components/icons/Filter.vue","./src/components/icons/Identification.vue","./src/components/icons/Menu.vue","./src/components/icons/SpeechBubbles.vue","./src/components/icons/Stack.vue","./src/components/icons/ThumbsDown.vue","./src/components/icons/ThumbsUp.vue","./src/components/modals/AmendmentModal.vue","./src/components/modals/FilterModal.vue","./src/components/modals/Modal.vue","./src/components/modals/ProposalModal.vue","./src/components/modals/RegistrationModal.vue","./src/views/HomeView.vue","./src/views/ProposalView.vue"],"errors":true,"version":"5.9.2"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/components/icons/index.ts","./src/components/icons/types.ts","./src/composables/useFilterNotifier.ts","./src/composables/useLoading.ts","./src/composables/useModal.ts","./src/composables/useSession.ts","./src/generated/typescript/index.ts","./src/generated/typescript/core/ApiError.ts","./src/generated/typescript/core/ApiRequestOptions.ts","./src/generated/typescript/core/ApiResult.ts","./src/generated/typescript/core/CancelablePromise.ts","./src/generated/typescript/core/OpenAPI.ts","./src/generated/typescript/core/request.ts","./src/generated/typescript/models/AddAmendmentRequest.ts","./src/generated/typescript/models/AddCommentRequest.ts","./src/generated/typescript/models/AddProposalRequest.ts","./src/generated/typescript/models/Amendment.ts","./src/generated/typescript/models/AmendmentParagraph.ts","./src/generated/typescript/models/AmendmentStatus.ts","./src/generated/typescript/models/AmendmentVote.ts","./src/generated/typescript/models/AmendmentWithParagraphs.ts","./src/generated/typescript/models/AmendmentWithSummary.ts","./src/generated/typescript/models/CommentWithUser.ts","./src/generated/typescript/models/FreighterLoginRequest.ts","./src/generated/typescript/models/LoginRequest.ts","./src/generated/typescript/models/Proposal.ts","./src/generated/typescript/models/ProposalParagraph.ts","./src/generated/typescript/models/ProposalParagraphRequest.ts","./src/generated/typescript/models/ProposalVote.ts","./src/generated/typescript/models/ProposalWithParagraphs.ts","./src/generated/typescript/models/ProposalWithSummary.ts","./src/generated/typescript/models/RegisterRequest.ts","./src/generated/typescript/models/SelectableAmendment.ts","./src/generated/typescript/models/SelectableComment.ts","./src/generated/typescript/models/SelectableProposal.ts","./src/generated/typescript/models/UserAmendmentVote.ts","./src/generated/typescript/models/UserProposalVotes.ts","./src/generated/typescript/models/VoteAmendmentRequest.ts","./src/generated/typescript/models/VoteAmendmentResponse.ts","./src/generated/typescript/models/VoteChoice.ts","./src/generated/typescript/models/VoteProposalRequest.ts","./src/generated/typescript/schemas/$AddProposalRequest.ts","./src/generated/typescript/schemas/$FreighterLoginRequest.ts","./src/generated/typescript/schemas/$ListProposalsResponse.ts","./src/generated/typescript/schemas/$LoginRequest.ts","./src/generated/typescript/schemas/$ProposalItem.ts","./src/generated/typescript/schemas/$ProposalList.ts","./src/generated/typescript/schemas/$RegisterRequest.ts","./src/generated/typescript/schemas/$VisitProposalRequest.ts","./src/generated/typescript/services/AmendmentService.ts","./src/generated/typescript/services/AuthService.ts","./src/generated/typescript/services/CommentService.ts","./src/generated/typescript/services/ProposalService.ts","./src/router/index.ts","./src/stores/filterStore.ts","./src/types/color.ts","./src/types/comment.ts","./src/types/dropdown.ts","./src/types/headerletter.ts","./src/types/proposal.ts","./src/types/user.ts","./src/types/vote.ts","./src/utils/amendment.ts","./src/utils/auth.ts","./src/utils/eventBus.ts","./src/utils/markdown.ts","./src/utils/proposalExport.ts","./src/utils/wallet.ts","./src/App.vue","./src/components/AmendmentCarousel.vue","./src/components/BrandBar.vue","./src/components/Chip.vue","./src/components/ColoredHeader.vue","./src/components/Dropdown.vue","./src/components/LoadingSpinner.vue","./src/components/PostBuilder.vue","./src/components/ProposalContainer.vue","./src/components/ProposalDetail.vue","./src/components/ProposalEmptyState.vue","./src/components/ProposalExportCard.vue","./src/components/ProposalVotingCard.vue","./src/components/Sidebar.vue","./src/components/Thread.vue","./src/components/VotingControls.vue","./src/components/icons/Abstain.vue","./src/components/icons/AppIcon.vue","./src/components/icons/Arrow.vue","./src/components/icons/Cancel.vue","./src/components/icons/Confirm.vue","./src/components/icons/DownArrow.vue","./src/components/icons/Download.vue","./src/components/icons/Filter.vue","./src/components/icons/IconBase.vue","./src/components/icons/Identification.vue","./src/components/icons/Menu.vue","./src/components/icons/Plus.vue","./src/components/icons/SpeechBubbles.vue","./src/components/icons/Stack.vue","./src/components/icons/ThumbsDown.vue","./src/components/icons/ThumbsUp.vue","./src/components/modals/AmendmentModal.vue","./src/components/modals/FilterModal.vue","./src/components/modals/Modal.vue","./src/components/modals/NewAmendmentModal.vue","./src/components/modals/ProposalModal.vue","./src/components/modals/RegistrationModal.vue","./src/views/HomeView.vue","./src/views/ProposalView.vue"],"errors":true,"version":"5.9.2"}

Laden…
Annuleren
Opslaan