Author SHA1 Message Date
Daan Meijer cca5952470 feat: Implement defensive point constraints and validation tests 2026-06-23 11:41:20 +02:00
Daan Meijer d68fc33bcb extra documentation updates
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 17:27:36 +02:00
Daan Meijer 9c270973ed chat improvements
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 16:50:08 +02:00
Daan Meijer de88658a48 show messages directly when sending them 2026-06-22 16:27:54 +02:00
Daan Meijer 0f8edfb827 polishing, mutationlist gerepareerd
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 16:16:27 +02:00
Daan Meijer 188c4435cb standardization of policies/permissions
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 16:13:32 +02:00
Daan Meijer c60033b365 polishing, some breadcrumbs, predefined mutations implemented more 2026-06-22 15:57:04 +02:00
Daan Meijer 4ce510402c participants shouldn't show ids either
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 15:32:59 +02:00
Daan Meijer 77c3e34d5b polishing
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 47s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 15:21:27 +02:00
Daan Meijer f3d5be6a80 better target for message notifications
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m10s
2026-06-22 13:55:38 +02:00
Daan Meijer 10bd46a53e formatting, juiste use voor UpdateDynamicRequest
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 00:10:39 +02:00
Daan Meijer 3e473de826 correct checken of iemand dynamic settings kan aanpassen
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-22 00:08:22 +02:00
Daan Meijer 64d6214aed removed vite-pwa
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-22 00:01:50 +02:00
Daan Meijer 2b28831c2f web push nu zonder axios
linter / quality (push) Failing after 1m4s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.5) (push) Failing after 1m7s
tests / ci (8.4) (push) Failing after 10m2s
2026-06-21 23:55:36 +02:00
Daan Meijer 5ad018ed6e retry voor web push
linter / quality (push) Failing after 1m7s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m6s
2026-06-21 23:45:50 +02:00
Daan Meijer bcf583866a fix voor build locatie van notificatie service worker
linter / quality (push) Failing after 1m7s
tests / ci (8.3) (push) Failing after 50s
tests / ci (8.4) (push) Failing after 1m9s
tests / ci (8.5) (push) Failing after 1m7s
2026-06-21 23:36:07 +02:00
Daan Meijer 98dc8659ba web push notifications
linter / quality (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m2s
2026-06-21 23:17:33 +02:00
Daan Meijer 1e0782385b over naar uuids, policies, predefined mutations aan kunnen passen
linter / quality (push) Failing after 1m5s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m5s
tests / ci (8.3) (push) Failing after 12m5s
2026-06-21 23:09:38 +02:00
Daan Meijer 06e5600447 ui improvements
linter / quality (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 52s
tests / ci (8.4) (push) Failing after 1m9s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 23:07:59 +02:00
Daan Meijer ed16d5dcda feat: Implement chat pagination, participant single page with mutations, and fix user link substitutions 2026-06-17 22:55:11 +02:00
Daan Meijer 11df4ef55c further development of the predefinedmutations
linter / quality (push) Failing after 1m16s
tests / ci (8.3) (push) Failing after 59s
tests / ci (8.4) (push) Failing after 1m14s
tests / ci (8.5) (push) Failing after 1m15s
2026-06-17 13:30:55 +02:00
Daan Meijer ed23bb2a78 docs: Document ActivityService and system message architecture in AGENTS.md and DECISIONS.md 2026-06-17 11:06:59 +02:00
Daan Meijer ec8cbff770 refactor: Use ActivityService to create system messages in seeder 2026-06-17 11:06:59 +02:00
Daan Meijer c6d482e3de better system messages, display name relocation
linter / quality (push) Failing after 1m6s
tests / ci (8.3) (push) Failing after 54s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.5) (push) Failing after 1m13s
2026-06-17 11:00:35 +02:00
Daan Meijer 0fee3c1972 different names in different dynamics
linter / quality (push) Failing after 1m9s
tests / ci (8.3) (push) Failing after 51s
tests / ci (8.4) (push) Failing after 1m7s
tests / ci (8.5) (push) Failing after 1m9s
2026-06-17 09:38:54 +02:00
Daan Meijer 3c414e1ef1 refactoring of system messages
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:57:43 +02:00
Daan Meijer b9d2988e8c feat: Auto-close mobile navigation sidebar upon successful user navigation
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-17 00:43:26 +02:00
Daan Meijer 69762667bc style: Center non-chat system activity messages in chat streams
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:40:12 +02:00
Daan Meijer f493be6512 dynamiccontroller AuthorizesRequests
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:37:18 +02:00
Daan Meijer eeb3bf85a9 no applayout
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-06-17 00:36:15 +02:00
Daan Meijer ef00a73daa inertialinks
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
2026-06-17 00:34:36 +02:00
Daan Meijer ae6a4304f9 feat: Restrict ledger creation exclusively to dynamic owners and fix route matching conflicts
linter / quality (push) Failing after 1m0s
tests / ci (8.3) (push) Failing after 47s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-17 00:29:40 +02:00
Daan Meijer d01091d5a6 split extra screens from dynamic single
linter / quality (push) Failing after 1m3s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:27:37 +02:00
Daan Meijer 8f9c4c9d1f feat: Send chat messages on Enter keypress and optimize media test environments
linter / quality (push) Failing after 1m0s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:17:01 +02:00
Daan Meijer 5b1a634d80 feat: Implement dynamic settings page
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:13:31 +02:00
Daan Meijer 379543a351 AI project docuimentation
linter / quality (push) Failing after 1m6s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.5) (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 13m41s
2026-06-16 18:13:06 +02:00
Daan Meijer 06cd53fe91 added invitations
linter / quality (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-16 16:29:17 +02:00
Daan Meijer 5404b1a535 removed placeholders
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m6s
2026-06-16 15:13:37 +02:00
Daan Meijer cb01acd6e0 trust docker upstream proxies
linter / quality (push) Failing after 1m5s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.5) (push) Failing after 1m6s
2026-06-16 15:11:34 +02:00
Daan Meijer 4e9e6dce2a front-end restructuring, chat shows message ownership
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-16 14:13:08 +02:00
Daan Meijer d44bcf6fda formatting, browser tests 2026-06-16 10:53:44 +02:00
Daan Meijer 9a9a901d46 tests 2026-06-16 10:30:50 +02:00
Daan Meijer a687d7ac4d docs: Update IDEA.md with session achievements and implementation details
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 52s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-15 23:41:11 +02:00
Daan Meijer f12639fb59 feat: Implement read cursor activity tracking and recent activity dashboard with context 2026-06-15 23:37:44 +02:00
Daan Meijer e32fbc10e0 style: Align negative ledger mutation score values and demerit math in seeder 2026-06-15 23:26:57 +02:00
Daan Meijer 94aae59f8e docs: Document clean environment test execution learnings in AGENTS.md 2026-06-15 23:24:26 +02:00
Daan Meijer 3dd7c48b08 feat: Implement Ledger alignment and correct corrective mutation authors in seeders 2026-06-15 23:22:08 +02:00
Daan Meijer bf7da48a58 style: Refactor Vue pages and custom layout components to scoped BEM 2026-06-15 23:13:54 +02:00
145 changed files with 7478 additions and 962 deletions
+4
View File
@@ -63,3 +63,7 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VITE_VAPID_PUBLIC_KEY="${VAPID_PUBLIC_KEY}"
+2
View File
@@ -27,3 +27,5 @@ yarn-error.log
/.nova /.nova
/.vscode /.vscode
/.zed /.zed
/public/sw.js
/public/workbox-*.js
-5
View File
@@ -5,11 +5,6 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Persistent Project Context (IMPORTANT)
- You MUST read, understand, and strictly follow the underlying business logic and feature requests documented in `IDEA.md` in every session.
- You MUST adhere to all style architecture, backend transaction, and integration decisions recorded in `DECISIONS.md`. Update `DECISIONS.md` when any major design decisions are made during a session.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+54 -5
View File
@@ -19,18 +19,67 @@ This document outlines the decisions made during the development of the Ledgerrz
## Architectural & Integration Decisions ## Architectural & Integration Decisions
### 1. Style Architecture Shift to BEM ### 1. Style Architecture Shift to BEM & Modern CSS Nesting
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks. To keep the look and feel 100% intact, we used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly. To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks (or dedicated BEM component files under `/resources/css/components/`).
*Note:* Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity. * **CSS Nesting Standard (Critical Learning):** Standard CSS/PostCSS nesting (unlike SASS) **does not** support class suffix concatenation (e.g. `.block { &__element { ... } }` is invalid). All nested selectors must declare the full class name explicitly (e.g. `.block { .block__element { ... } }`), compiling to standard descendant selectors (`.block .block__element`). Suffix nesting was refactored across all 12 component stylesheets to ensure native CSS browser and LightningCSS compatibility.
* **Encapsulation:** Used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
* **Third-Party Safety:** Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
### 2. Robust Real-time Echo Fallback & Deduplication ### 2. BelongsToMany Pivot Loading caveat
When accessing pivot attributes on relationship models on the front-end (e.g. `participant.pivot.role`), the relationship definition in the Eloquent model **must** explicitly call `->withPivot('role')`. Omitting this will cause the pivot object to fail loading role attributes silently in Javascript, breaking role-based template gates. We added this to `App\Models\Dynamic::participants()`.
### 3. Robust Real-time Echo Fallback & Deduplication
To address potential initialization and bundling errors under local-development: To address potential initialization and bundling errors under local-development:
* **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles. * **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles.
* **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally). * **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally).
### 3. Controller Authorization Fix ### 4. Controller Authorization Fix
We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`. We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`.
### 5. Polymorphic Multiple-Media Attachment Support
We added a polymorphic attachments system allowing any database model to attach multiple photos and videos cleanly:
* Created `Media` polymorphic model and a migration mapping `mediable_id` and `mediable_type`.
* Attached and verified uploads across `Message` (inline chat images/videos), `Ledger` (cover documents), and `Mutation` (chore submission receipts and proof).
* Built a highly reusable `.c-lightbox` CSS component for modal previews of both image and video uploads, avoiding duplication in individual modules.
### 6. Cryptographically Secure Dynamic Invitation System
We implemented a secure Dynamic Invitation flow to allow owners to invite new members to a Dynamic under a specific role:
* **Signed temporary URLs:** Invitation links dispatched in mailable emails (`DynamicInvitationMail.php`) use Laravel's temporary signed URLs, expiring in 7 days, to prevent link tampering.
* **Intended-Email Check Gate:** To prevent link hijacking, the accept gate strictly checks that the authenticated user's email matches the exact email specified on the invitation:
```php
if ($request->user()->email !== $invitation->email) {
abort(403, 'This invitation was sent to a different email address.');
}
```
* **Access Control:** Access to invitations and creation is fully protected under Owner-only authorization checks.
### 7. Centralized Activity Service for System Messages
We created `app/Services/ActivityService.php` to centralize the creation of system messages and activities.
* **System Messages as `null` user_id**: System messages are stored as `Message` records with a `null` `user_id`, cleanly distinguishing them from user-generated content.
* **Polymorphic Subject Linking**: System messages are linked to relevant entities (e.g., a `User` who joined a dynamic, a `Ledger` that was created) via a polymorphic `subject` relationship on the `messages` table. This allows system messages on the dashboard to link directly to the relevant entity.
* **Seeder Refactoring**: The `DatabaseSeeder` was refactored to use the `ActivityService` to generate all system messages, ensuring consistency.
### 8. Event-Driven Automated System Logging
We relocated the dynamic system activity message generation out of individual controller endpoints and into the **Eloquent `Mutation` Model's `booted` -> `created` event hook**.
* **Unified generation:** Any mutation creation (whether occurring via a controller submission, an automated Pest test factory, or seeders during `php artisan db:seed`) now automatically and reliably generates correct system log messages.
* **Database Seeder fixed:** Reverted the database seeder (`DatabaseSeeder.php`) back to using standard clean Eloquent creations. Since model events automatically trigger, `php artisan db:seed` executes cleanly and builds a rich, fully populated database history with system messages out-of-the-box.
### 9. Standardized Policy-Driven UI Capabilities (`can` prop)
To maintain strict data security and clean up front-end markup, we eliminated unstandardized, hardcoded client-side role checks (like `isOwner` properties or manual `pivot.role === 'owner'` checks) and replaced them completely with dynamic **policy-driven capabilities** returned directly from Laravel policies as `can` objects.
* **Centralized checks:** Both the dynamic and ledger show routes return a standard `can` prop to the frontend (e.g., `can: { update: boolean, close: boolean }`).
* **Polymorphic Resource Capabilities:** Each mutation model is wrapped in `MutationResource` which appends its own localized policy checks (`update` for approving suggestions, `void` for voiding) at the individual record level.
* **State-based policies:** The `MutationPolicy` methods enforce both ownership authorization and state-based business constraints simultaneously (e.g., a mutation can be approved/updated *only* if its status is currently `'pending'`; and can be voided *only* if its status is not `'voided'`). This keeps the Vue templates purely declarative (e.g., `v-if="mutation.can.update"`) and automatically protects the backend controllers against illegal state transitions.
### 10. Ledger-Scoped Predefined Mutation Templates ("Rewards")
Predefined mutations act as point-based templates ("purchases" or reusable chores) and belong strictly to specific **Ledgers** instead of broad Dynamics.
* **Domain Alignment:** Moving the resource nesting under ledgers (`dynamics.ledgers.predefined-mutations`) aligns perfectly with the mental model of spending points on a ledger.
* **Type-Less Rewards:** To simplify both the database schema and UI/UX, we eliminated the explicit `type` column ('reward' vs. 'penalty') from predefined mutations. They act as generic point-carrying "Rewards" whose amount can naturellement be positive (earning points) or negative (making a purchase / deducting points) without requiring restrictive explicit categorization.
### 11. Silent XHR Chat Pagination & Smooth Scrolling UX
To optimize chat-feed performance and improve overall user experience:
* **Silent pagination (No URL pollution):** Rather than using Inertia `router.get` visits which push `?page=x` into the browser URL and break history during page reloads, we implemented a **silent background fetch** (using native browser `fetch()`) that queries our dedicated messages JSON API endpoints and prepends older messages silently to the feed.
* **Scroll Preservation:** Added `preserveScroll: true` to the Inertia `form.post` call in `Chat.vue` to prevent the active page scroll position from jumping or shifting when a new message is successfully submitted.
## Initial Database Schema ## Initial Database Schema
I will start with a basic schema and evolve it as I build features. I will start with a basic schema and evolve it as I build features.
+16 -1
View File
@@ -14,4 +14,19 @@ Welcome to the Ledgerrz codebase! This file defines the persistent guidelines, a
* **PHP/Laravel:** PHP 8.4 & Laravel 13. Adhere to typed parameters and return values. Ensure controllers extend properly and use required authorization traits (e.g., `AuthorizesRequests`). * **PHP/Laravel:** PHP 8.4 & Laravel 13. Adhere to typed parameters and return values. Ensure controllers extend properly and use required authorization traits (e.g., `AuthorizesRequests`).
* **Frontend Styling (BEM):** Replaced direct Tailwind inline utility-class markup with **BEM (Block, Element, Modifier)**. All custom component styles must live inside `<style scoped>` blocks with a relative `@reference "../../css/app.css"` directive to pull variables without duplications. * **Frontend Styling (BEM):** Replaced direct Tailwind inline utility-class markup with **BEM (Block, Element, Modifier)**. All custom component styles must live inside `<style scoped>` blocks with a relative `@reference "../../css/app.css"` directive to pull variables without duplications.
* **Real-time Broadcasting:** Powered by `@laravel/echo-vue` with fallback configurations and Vite deduplication rules configured in `vite.config.ts`. * **Real-time Broadcasting:** Powered by `@laravel/echo-vue` with fallback configurations and Vite deduplication rules configured in `vite.config.ts`.
* **Testing:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running `vendor/bin/pest`. * **Testing & Isolation:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running tests. To prevent local `.env` variables from polluting the CLI test execution (causing CSRF/session 419 errors), **always** run tests in an isolated environment using:
```bash
env -i PATH="$PATH" php artisan test
```
* **Standardized Authorization (`can` prop):** Never write manual role checks (such as `pivot.role === 'owner'`) or hardcoded boolean flags (such as `isOwner`) inside Vue pages or components. Instead, always leverage Laravel policies on the backend and pass permissions reactively to the frontend as structured `can` objects (e.g., `can: { update: boolean, close: boolean }`).
* **Vue-Defined Breadcrumbs Layout:** All page-specific breadcrumbs should be declared locally inside the page's `.vue` file rather than returned from controllers. For dynamic, prop-dependent paths, always use the Inertia v3 layout callback function inside `defineOptions`:
```typescript
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{ title: 'Dynamics', href: route('dynamics.index') },
{ title: props.dynamic.name, href: route('dynamics.show', props.dynamic.id) }
]
})
});
```
+56
View File
@@ -9,3 +9,59 @@ Dynamics have a rules-segment too. Not all people within a dynamic can edit thes
The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future. The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future.
Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features. Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features.
---
## Session Developments & Achievements
During this session, we successfully built out and verified several core architectural features of the Ledgerrz application:
1. **BEM Styling Refactoring (BDSM Theme first)**:
* Replaced inline Tailwind classes across all core Vue pages and layout components (`Dynamics/Index.vue`, `Dynamics/Create.vue`, `Dynamics/Show.vue`, `Ledgers/Show.vue`, `ParticipantsList.vue`, `LedgerList.vue`, `CreateLedgerForm.vue`, `AddMutationForm.vue`, and `MutationList.vue`) with structured BEM scoped CSS blocks.
* Utilized Tailwind v4 `@apply` and `@reference` inside `<style scoped>` blocks to prevent code duplication, enforce a dark-first BDSM aesthetic, and maintain clear separation of concerns.
2. **Ledger Alignment & Scoring Logic**:
* Added an `alignment` column (`positive`, `neutral`, `negative`) to Ledgers, affecting the color coding of mutations:
* *Positive Alignment*: higher score is better; additions are green (+), demerits/deductions are red (-).
* *Negative Alignment*: lower score is better (demerits); deductions are green (-) as they reduce demerits, and additions/infractions are red (+) as they increase demerits.
* *Neutral Alignment*: standard scoring, styled in gray/neutral colors.
* Added an "Alignment" dropdown selector in the ledger creation form.
3. **Demerit Authorship Correction**:
* Corrected database seeders so demerits/penalty mutations are authored by the Dominant/Owner user (`alice` or `testUser`) rather than the submissive (`bob`), aligning with realistic power exchange dynamics.
4. **Read Cursor & Dashboard Activities Highlights**:
* Designed and built a polymorphic `read_cursors` table to track a user's last-viewed timestamp on individual Dynamics and Ledgers.
* Developed `ActivityService` to chronological sort and partition dynamic activity (messages, ledger creation) and ledger activity (mutations, approval updates, and comments) into unread vs. read categories.
* Highlighted unread entities grouped together on the user's dashboard, displaying the **last two already-read items as muted context** followed by highlighted unread activities with a prominent `NEW` badge.
* Visiting a Dynamic/Ledger automatically updates its read cursor, removing it from subsequent dashboard loads.
* Dynamically shared the unread entity count as a global Inertia prop, rendering a vibrant red dot notification badge over a bell icon in the top-right of the header.
5. **Broadcasts, Environment & Verification**:
* Configured real-time notifications utilizing Laravel Reverb.
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors.
* Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**.
6. **Ledger-Scoped Predefined Mutation Templates ("Rewards")**:
* Associated reusable point-based predefined mutation templates under specific Ledgers rather than broad Dynamics, mapping perfectly to the mental model of spending points on a ledger.
* Designed them purely as "Rewards" with positive or negative point amounts (handling both demerit-purchases and chores), removing the obsolete `type` categorization for a simpler, type-less, and sleeker UI/UX.
7. **User Activity Profiling & Detail Pages**:
* Created a dynamic user detail page (`dynamics.users.show`) scoped to each dynamic. It displays a participant's role, custom display name, fallback real name, and a clean chronological listing of their 10 most recent mutations (activities) in that dynamic.
8. **Polymorphic System Message placeholders & Dynamic Client-Side Linking**:
* Refactored system log activity messages to use native `<user:userId>` placeholders and associated them with polymorphic `subject_id` and `subject_type` objects.
* On the client-side, the chat component parses these placeholders into rich, clickable links to User Profiles, and dynamically matches and wraps referenced ledger names into links pointing directly to the ledger show page.
* Added backend-side placeholder resolution inside `ActivityService` for the dashboard, ensuring unread system logs translate cleanly to real names across multiple dynamics.
9. **Vite/Inertia v3 Layout Callback Breadcrumbs**:
* Utilized Inertia v3's powerful new layout callback API inside Vue page `defineOptions` to reactively resolve page-specific dynamic breadcrumbs at runtime using parsed page props, making the pages self-contained and keeping PHP controllers beautifully slim.
10. **Silent background Chat Pagination & Smooth Scrolling UX**:
* Implemented silent background XHR queries (using native browser `fetch()`) on our dedicated message JSON API routes to load older chat pages, completely bypassing browser history/URL pollution and preserving page state on refreshes.
* Integrated `preserveScroll: true` inside chat form submissions to completely prevent scroll jumps when sending messages.
11. **Standardized Policy-Driven UI Capabilities**:
* Eliminated unstandardized client-side role checks and boolean flags, replacing them with structured `can` capability objects returned directly from Laravel policies.
* Combined permission validation with state-based business constraints in `MutationPolicy` (e.g., suggestions can be approved/rejected only if `'pending'`; and voided only if not `'voided'`), securing both the frontend action buttons and backend controllers simultaneously.
* Achieved **65/65 passing Pest PHP tests with 333 assertions**.
+1
View File
@@ -30,6 +30,7 @@ class MutationCreated implements ShouldBroadcast
public function broadcastOn(): array public function broadcastOn(): array
{ {
$chatId = $this->mutation->ledger->dynamic->chat->id; $chatId = $this->mutation->ledger->dynamic->chat->id;
return [ return [
new PrivateChannel('chats.'.$chatId), new PrivateChannel('chats.'.$chatId),
]; ];
+1
View File
@@ -30,6 +30,7 @@ class MutationUpdated implements ShouldBroadcast
public function broadcastOn(): array public function broadcastOn(): array
{ {
$chatId = $this->mutation->ledger->dynamic->chat->id; $chatId = $this->mutation->ledger->dynamic->chat->id;
return [ return [
new PrivateChannel('chats.'.$chatId), new PrivateChannel('chats.'.$chatId),
]; ];
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\ActivityService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index(Request $request, ActivityService $activityService)
{
$user = $request->user();
$unreadDynamics = $activityService->getUnreadDynamicsGrouped($user);
return Inertia::render('Dashboard', [
'unreadDynamics' => $unreadDynamics,
]);
}
}
+35 -13
View File
@@ -2,8 +2,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreDynamicRequest; use App\Http\Requests\UpdateDynamicRequest;
use App\Http\Resources\DynamicResource;
use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
@@ -11,13 +16,14 @@ use Inertia\Inertia;
class DynamicController extends Controller class DynamicController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Request $request) public function index(Request $request)
{ {
return Inertia::render('Dynamics/Index', [ return Inertia::render('Dynamics/Index', [
'dynamics' => $request->user()->dynamics()->get(), 'dynamics' => DynamicResource::collection($request->user()->dynamics()->get()),
]); ]);
} }
@@ -44,36 +50,52 @@ class DynamicController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Dynamic $dynamic) public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
$dynamic->load([ $activityService->updateCursor($request->user(), $dynamic);
'ledgers.media',
'participants', $dynamic->load(['ledgers.media', 'participants', 'chat']);
'chat.messages.user',
'chat.messages.media'
]);
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'ledgers' => LedgerResource::collection($dynamic->ledgers),
'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT)),
'can' => [
'update' => $request->user()->can('update', $dynamic),
],
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic)
{
$this->authorize('view', $dynamic);
return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT));
}
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
public function edit(Dynamic $dynamic) public function edit(Dynamic $dynamic)
{ {
// $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Settings', [
'dynamic' => new DynamicResource($dynamic),
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Dynamic $dynamic) public function update(UpdateDynamicRequest $request, Dynamic $dynamic)
{ {
// $dynamic->update($request->validated());
return redirect()->route('dynamics.show', $dynamic);
} }
/** /**
@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers;
use App\Mail\DynamicInvitationMail;
use App\Models\Dynamic;
use App\Models\DynamicInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Inertia\Inertia;
class DynamicInvitationController extends Controller
{
use AuthorizesRequests;
/**
* Show the form for creating a new invitation.
*/
public function create(Request $request, Dynamic $dynamic)
{
// Authorize - only owners can view the invite page!
$this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Invite', [
'dynamic' => $dynamic,
]);
}
/**
* Store a newly created invitation in storage.
*/
public function store(Request $request, Dynamic $dynamic)
{
// 1. Authorize - only owners can send invitations!
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (! $isOwner) {
abort(403, 'Only dynamic owners can invite other users.');
}
// 2. Validate
$request->validate([
'email' => ['required', 'email'],
'role' => ['required', 'string', 'in:owner,participant,editor,viewer'],
]);
$email = $request->input('email');
$role = $request->input('role');
// Check if user is already a participant of this dynamic
$isParticipant = $dynamic->participants()->where('email', $email)->exists();
if ($isParticipant) {
return redirect()->back()->withErrors([
'email' => 'This user is already a participant of this dynamic.',
]);
}
// Check if there is an active pending invitation for this user
$hasPendingInvite = $dynamic->invitations()
->where('email', $email)
->where('expires_at', '>', now())
->exists();
if ($hasPendingInvite) {
return redirect()->back()->withErrors([
'email' => 'An active invitation is already pending for this email address.',
]);
}
// 3. Create Invitation
$invitation = $dynamic->invitations()->create([
'email' => $email,
'role' => $role,
'token' => Str::random(40),
'expires_at' => now()->addDays(7),
]);
// 4. Send Email
Mail::to($email)->send(new DynamicInvitationMail($invitation, $request->user()->name));
return redirect()->back()->with('success', 'Invitation successfully sent!');
}
/**
* Accept the specified invitation.
*/
public function accept(Request $request, string $token)
{
// Must be signed!
if (! $request->hasValidSignature()) {
abort(401, 'Invalid or expired signature.');
}
$invitation = DynamicInvitation::where('token', $token)->firstOrFail();
if ($invitation->isExpired()) {
abort(403, 'This invitation has expired.');
}
// Ensure the logged in user's email matches the invitation's email!
// "Only the user with the specified email address should be able to access the link."
if ($request->user()->email !== $invitation->email) {
abort(403, 'This invitation was sent to a different email address.');
}
DB::transaction(function () use ($request, $invitation) {
// Attach user to dynamic as a participant with the specified role
$dynamic = $invitation->dynamic;
$dynamic->participants()->attach($request->user()->id, ['role' => $invitation->role]);
// Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$request->user()->id}> joined the Dynamic as a ".strtoupper($invitation->role),
'subject_id' => $request->user()->id,
'subject_type' => User::class,
]);
// Delete the invitation record
$invitation->delete();
});
return redirect()->route('dynamics.show', $invitation->dynamic)->with('success', 'Successfully joined the dynamic!');
}
}
+56 -18
View File
@@ -3,10 +3,16 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreLedgerRequest; use App\Http\Requests\StoreLedgerRequest;
use App\Http\Resources\DynamicResource;
use App\Http\Resources\LedgerResource;
use App\Http\Resources\MessageResource;
use App\Http\Resources\MutationResource;
use App\Http\Resources\UserResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use Illuminate\Http\Request; use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
class LedgerController extends Controller class LedgerController extends Controller
@@ -24,9 +30,13 @@ class LedgerController extends Controller
/** /**
* Show the form for creating a new resource. * Show the form for creating a new resource.
*/ */
public function create() public function create(Request $request, Dynamic $dynamic)
{ {
// $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/Create', [
'dynamic' => new DynamicResource($dynamic),
]);
} }
/** /**
@@ -34,6 +44,7 @@ class LedgerController extends Controller
*/ */
public function store(StoreLedgerRequest $request, Dynamic $dynamic) public function store(StoreLedgerRequest $request, Dynamic $dynamic)
{ {
$this->authorize('create', [Ledger::class, $dynamic]);
$ledger = $dynamic->ledgers()->create($request->except('media')); $ledger = $dynamic->ledgers()->create($request->except('media'));
if ($request->hasFile('media')) { if ($request->hasFile('media')) {
@@ -53,10 +64,12 @@ class LedgerController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Request $request, Dynamic $dynamic, Ledger $ledger) public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService)
{ {
$this->authorize('view', $ledger); $this->authorize('view', $ledger);
$activityService->updateCursor($request->user(), $ledger);
$dynamic->load('chat', 'participants'); $dynamic->load('chat', 'participants');
$ledger->load([ $ledger->load([
@@ -66,36 +79,61 @@ class LedgerController extends Controller
}, },
'mutations.user', 'mutations.user',
'mutations.media', 'mutations.media',
'mutations.chat.messages.user', 'mutations.chat',
'mutations.chat.messages.media'
]); ]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Ledgers/Show', [ return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic, 'dynamic' => new DynamicResource($dynamic),
'ledger' => $ledger, 'ledger' => new LedgerResource($ledger),
'isOwner' => $isOwner, 'mutations' => MutationResource::collection($ledger->mutations),
'participants' => UserResource::collection($dynamic->participants),
'messages' => MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT)),
'can' => [
'update' => $request->user()->can('update', $ledger),
'close' => $request->user()->can('close', $ledger),
],
]); ]);
} }
public function messages(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('view', $ledger);
return MessageResource::collection($dynamic->chat->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT));
}
/** /**
* Show the form for editing the specified resource. * Show the form for editing the specified resource.
*/ */
public function edit(Ledger $ledger) public function edit(Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
return Inertia::render('Ledgers/Edit', [
'dynamic' => new DynamicResource($dynamic),
'ledger' => new LedgerResource($ledger),
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Ledger $ledger) public function update(StoreLedgerRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
// $this->authorize('update', $ledger);
$ledger->update($request->validated());
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
public function close(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('close', $ledger);
$ledger->update(['status' => 'closed']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
/** /**
+38 -49
View File
@@ -2,15 +2,22 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\MessageSent;
use App\Events\MutationCreated;
use App\Events\MutationUpdated;
use App\Http\Requests\StoreMutationRequest; use App\Http\Requests\StoreMutationRequest;
use App\Http\Resources\MutationResource;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class MutationController extends Controller class MutationController extends Controller
{ {
use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
@@ -32,13 +39,10 @@ class MutationController extends Controller
*/ */
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
$isOwner = $dynamic->participants() $this->authorize('create', [Mutation::class, $ledger]);
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
// If the user is an owner, default status to 'approved'. Otherwise default to 'pending'. // If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending'; $status = $request->user()->can('update', $ledger) ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) { $mutation = DB::transaction(function () use ($request, $ledger, $status) {
$mutation = $ledger->mutations()->create([ $mutation = $ledger->mutations()->create([
@@ -67,32 +71,8 @@ class MutationController extends Controller
return $mutation; return $mutation;
}); });
// Log to Mutation and Dynamic chats
$user = $request->user();
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id,
'content' => $status === 'approved'
? "System: Entry was created by {$user->name}."
: "System: Suggestion was created by {$user->name}.",
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} added entry \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} suggested \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time creation event! // Broadcast the real-time creation event!
broadcast(new \App\Events\MutationCreated($mutation)); broadcast(new MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
@@ -102,7 +82,9 @@ class MutationController extends Controller
*/ */
public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// $this->authorize('view', $mutation);
return new MutationResource($mutation);
} }
/** /**
@@ -118,15 +100,7 @@ class MutationController extends Controller
*/ */
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// 1. Authorize - only owners can update mutation status! $this->authorize('update', $mutation);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (!$isOwner) {
abort(403, 'Only dynamic owners can approve or reject mutations.');
}
$request->validate([ $request->validate([
'status' => ['required', 'string', 'in:approved,rejected'], 'status' => ['required', 'string', 'in:approved,rejected'],
@@ -151,30 +125,45 @@ class MutationController extends Controller
$statusText = strtoupper($newStatus); $statusText = strtoupper($newStatus);
$mutationMsg = $mutation->chat->messages()->create([ $mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: Suggestion was {$statusText} by {$user->name}.", 'content' => "Suggestion was {$statusText} by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
broadcast(new \App\Events\MessageSent($mutationMsg)); broadcast(new MessageSent($mutationMsg));
if ($newStatus === 'approved') { if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> APPROVED the suggestion \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} else { } else {
$dynamicMsg = $dynamic->chat->messages()->create([ $dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id, 'user_id' => null,
'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.", 'content' => "<user:{$user->id}> REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]); ]);
} }
broadcast(new \App\Events\MessageSent($dynamicMsg)); broadcast(new MessageSent($dynamicMsg));
// Broadcast the real-time update event! // Broadcast the real-time update event!
broadcast(new \App\Events\MutationUpdated($mutation)); broadcast(new MutationUpdated($mutation));
return redirect()->back(); return redirect()->back();
} }
public function void(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{
$this->authorize('void', $mutation);
$mutation->update(['status' => 'voided']);
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
}
/** /**
* Remove the specified resource from storage. * Remove the specified resource from storage.
*/ */
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ParticipantController extends Controller
{
use AuthorizesRequests;
public function update(Request $request, Dynamic $dynamic)
{
$request->validate([
'display_name' => ['required', 'string', 'max:255'],
]);
$participant = $dynamic->participants()->where('user_id', $request->user()->id)->firstOrFail();
$dynamic->participants()->updateExistingPivot($participant->id, [
'display_name' => $request->input('display_name'),
]);
return redirect()->back()->with('success', 'Display name updated successfully!');
}
public function show(Request $request, Dynamic $dynamic, User $user)
{
// Ensure both the authenticated user and the target user are in the dynamic
if (! $dynamic->participants()->where('user_id', $request->user()->id)->exists()) {
abort(403);
}
$participant = $dynamic->participants()->where('user_id', $user->id)->firstOrFail();
$mutations = $user->mutations()
->whereHas('ledger', fn ($query) => $query->where('dynamic_id', $dynamic->id))
->with('ledger')
->latest('id')
->take(10)
->get();
return Inertia::render('Dynamics/Participants/Show', [
'dynamic' => $dynamic,
'participant' => [
'id' => $user->id,
'name' => $user->name,
'display_name' => $participant->pivot->display_name,
'role' => $participant->pivot->role,
],
'mutations' => $mutations,
]);
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\PredefinedMutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PredefinedMutationController extends Controller
{
use AuthorizesRequests;
/**
* Display a listing of the resource.
*/
public function index(Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('update', $dynamic);
return Inertia::render('Ledgers/PredefinedMutations/Index', [
'dynamic' => $dynamic,
'ledger' => $ledger,
'predefined_mutations' => $ledger->predefinedMutations()->latest()->get(),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Dynamic $dynamic, Ledger $ledger)
{
$this->authorize('update', $dynamic);
$request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
]);
$ledger->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
return Inertia::render('Ledgers/PredefinedMutations/Edit', [
'dynamic' => $dynamic,
'ledger' => $ledger,
'predefined_mutation' => $predefinedMutation,
]);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
]);
$predefinedMutation->update($request->all());
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Dynamic $dynamic, Ledger $ledger, PredefinedMutation $predefinedMutation)
{
$this->authorize('update', $dynamic);
$predefinedMutation->delete();
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]);
}
}
@@ -19,9 +19,14 @@ class ProfileController extends Controller
*/ */
public function edit(Request $request): Response public function edit(Request $request): Response
{ {
$dynamics = $request->user()->dynamics()
->withPivot('display_name')
->get();
return Inertia::render('settings/Profile', [ return Inertia::render('settings/Profile', [
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail, 'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
'status' => $request->session()->get('status'), 'status' => $request->session()->get('status'),
'dynamics' => $dynamics,
]); ]);
} }
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class WebPushController extends Controller
{
public function store(Request $request)
{
$request->validate([
'endpoint' => 'required',
'keys.p256dh' => 'required',
'keys.auth' => 'required',
]);
$endpoint = $request->endpoint;
$token = $request->keys['auth'];
$key = $request->keys['p256dh'];
Auth::user()->updatePushSubscription($endpoint, $key, $token);
return response()->json(['success' => true]);
}
public function destroy(Request $request)
{
$request->validate([
'endpoint' => 'required',
]);
Auth::user()->deletePushSubscription($request->endpoint);
return response()->json(['success' => true]);
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Services\ActivityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@@ -42,6 +43,15 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(), 'user' => $request->user(),
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'unreadNotificationsCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
$service = app(ActivityService::class);
return count($service->getUnreadDynamicsGrouped($request->user()));
},
]; ];
} }
} }
+2 -1
View File
@@ -14,7 +14,7 @@ class StoreLedgerRequest extends FormRequest
{ {
$dynamic = $this->route('dynamic'); $dynamic = $this->route('dynamic');
return $dynamic && $this->user()->can('view', $dynamic); return $dynamic && $this->user()->can('update', $dynamic);
} }
/** /**
@@ -27,6 +27,7 @@ class StoreLedgerRequest extends FormRequest
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'], 'rules' => ['nullable', 'string'],
'alignment' => ['required', 'string', 'in:positive,neutral,negative'],
'media' => ['nullable', 'array'], 'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'], 'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
]; ];
+1 -1
View File
@@ -25,7 +25,7 @@ class StoreMutationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'amount' => ['required', 'integer'], 'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'],
'description' => ['required', 'string'], 'description' => ['required', 'string'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDynamicRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$dynamic = $this->route('dynamic');
return $dynamic && $this->user()->can('update', $dynamic);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'],
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BaseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
if (isset($data['id']) && isset($this->uuid)) {
$data['id'] = $this->uuid;
}
return $data;
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class DynamicResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$result = parent::toArray($request);
if ($this->ledgers) {
$result['ledgers'] = LedgerResource::collection($this->ledgers);
}
if ($this->participants) {
$result['participants'] = ParticipantResource::collection($this->participants);
}
return $result;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class LedgerResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MessageResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$data = parent::toArray($request);
$data['can'] = [
'update' => $request->user()?->can('update', $this->resource) ?? false,
'void' => $request->user()?->can('void', $this->resource) ?? false,
];
return $data;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class ParticipantResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class PredefinedMutationResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return parent::toArray($request);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use App\Models\DynamicInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class DynamicInvitationMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public DynamicInvitation $invitation, public string $inviterName)
{
//
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Invitation to Join Dynamic: '.$this->invitation->dynamic->name,
);
}
public function content(): Content
{
$acceptUrl = URL::temporarySignedRoute(
'dynamics.invitations.accept',
$this->invitation->expires_at,
['token' => $this->invitation->token]
);
return new Content(
markdown: 'emails.dynamics.invitation',
with: [
'acceptUrl' => $acceptUrl,
'dynamicName' => $this->invitation->dynamic->name,
'role' => $this->invitation->role,
],
);
}
}
+4
View File
@@ -22,4 +22,8 @@ class Chat extends Model
{ {
return $this->hasMany(Message::class); return $this->hasMany(Message::class);
} }
public function getSubjectUrlAttribute(): string {
return $this->chatable?->url ?? '';
}
} }
+25 -1
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;
class Dynamic extends Model class Dynamic extends Model
{ {
@@ -21,7 +22,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'participants')->withPivot('role'); return $this->belongsToMany(User::class, 'participants')->withPivot('role', 'display_name');
} }
public function ledgers(): HasMany public function ledgers(): HasMany
@@ -29,6 +30,16 @@ class Dynamic extends Model
return $this->hasMany(Ledger::class); return $this->hasMany(Ledger::class);
} }
public function invitations(): HasMany
{
return $this->hasMany(DynamicInvitation::class);
}
public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
@@ -36,8 +47,21 @@ class Dynamic extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
static::created(function (Dynamic $dynamic) { static::created(function (Dynamic $dynamic) {
$dynamic->chat()->create([]); $dynamic->chat()->create([]);
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
public function getUrlAttribute(): string {
return route('dynamics.show', $this);
}
} }
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DynamicInvitation extends Model
{
use HasFactory;
protected $fillable = [
'dynamic_id',
'email',
'role',
'token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function dynamic(): BelongsTo
{
return $this->belongsTo(Dynamic::class);
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
}
+26 -1
View File
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
class Ledger extends Model class Ledger extends Model
{ {
@@ -18,6 +20,8 @@ class Ledger extends Model
'name', 'name',
'rules', 'rules',
'score', 'score',
'alignment',
'status',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo
@@ -30,8 +34,29 @@ class Ledger extends Model
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function predefinedMutations(): HasMany
{
return $this->hasMany(PredefinedMutation::class);
}
public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
public function getUrlAttribute(): string {
return route('dynamics.ledgers.show', $this);
}
} }
+7 -4
View File
@@ -6,7 +6,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Media extends Model { class Media extends Model
{
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
@@ -17,11 +18,13 @@ class Media extends Model {
protected $appends = ['url']; protected $appends = ['url'];
public function mediable(): MorphTo { public function mediable(): MorphTo
{
return $this->morphTo(); return $this->morphTo();
} }
public function getUrlAttribute(): string { public function getUrlAttribute(): string
return asset('storage/' . $this->file_path); {
return asset('storage/'.$this->file_path);
} }
} }
+16 -1
View File
@@ -6,16 +6,22 @@ use Database\Factories\MessageFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model class Message extends Model
{ {
/** @use HasFactory<MessageFactory> */ /** @use HasFactory<MessageFactory> */
use HasFactory; use HasFactory;
const PAGINATION_COUNT = 6;
protected $fillable = [ protected $fillable = [
'chat_id', 'chat_id',
'user_id', 'user_id',
'content', 'content',
'subject_id',
'subject_type',
]; ];
public function chat(): BelongsTo public function chat(): BelongsTo
@@ -28,8 +34,17 @@ class Message extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function subject(): MorphTo
{
return $this->morphTo();
}
public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
public function getSubjectUrlAttribute(): string {
return $this->subject->url ?? '';
}
} }
+52 -1
View File
@@ -2,11 +2,14 @@
namespace App\Models; namespace App\Models;
use App\Events\MessageSent;
use Database\Factories\MutationFactory; use Database\Factories\MutationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Support\Str;
class Mutation extends Model class Mutation extends Model
{ {
@@ -20,6 +23,7 @@ class Mutation extends Model
'amount', 'amount',
'description', 'description',
'status', 'status',
'predefined_mutation_id',
]; ];
public function ledger(): BelongsTo public function ledger(): BelongsTo
@@ -32,20 +36,67 @@ class Mutation extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function predefinedMutation(): BelongsTo
{
return $this->belongsTo(PredefinedMutation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
protected static function booted(): void protected static function booted(): void
{ {
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
static::created(function (Mutation $mutation) { static::created(function (Mutation $mutation) {
$mutation->chat()->create([]); $mutation->chat()->create([]);
// Create system messages automatically!
$user = $mutation->user;
$ledger = $mutation->ledger;
$dynamic = $ledger->dynamic;
$status = $mutation->status;
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => null,
'content' => $status === 'approved'
? "Entry was created by <user:{$user->id}>."
: "Suggestion was created by <user:{$user->id}>.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
broadcast(new MessageSent($mutationMsg));
if ($status === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> added entry \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => null,
'content' => "<user:{$user->id}> suggested \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
}
broadcast(new MessageSent($dynamicMsg));
}); });
} }
public function getRouteKeyName()
{
return 'uuid';
}
} }
+1
View File
@@ -12,5 +12,6 @@ class Participant extends Pivot
'user_id', 'user_id',
'dynamic_id', 'dynamic_id',
'role', 'role',
'display_name',
]; ];
} }
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class PredefinedMutation extends Model
{
use HasFactory;
protected $fillable = [
'ledger_id',
'name',
'description',
'amount',
];
public function ledger(): BelongsTo
{
return $this->belongsTo(Ledger::class);
}
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ReadCursor extends Model
{
protected $fillable = [
'user_id',
'cursorable_id',
'cursorable_type',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function cursorable(): MorphTo
{
return $this->morphTo();
}
}
+27 -1
View File
@@ -10,9 +10,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use NotificationChannels\WebPush\HasPushSubscriptions;
/** /**
* @property int $id * @property int $id
@@ -32,7 +34,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable implements PasskeyUser class User extends Authenticatable implements PasskeyUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; use HasFactory, HasPushSubscriptions, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
public function dynamics() public function dynamics()
{ {
@@ -44,6 +46,18 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function readCursors()
{
return $this->hasMany(ReadCursor::class);
}
public function displayNameFor(Dynamic $dynamic): string
{
$participant = $dynamic->participants()->where('user_id', $this->id)->first();
return $participant?->pivot?->display_name ?? $this->name;
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
@@ -57,4 +71,16 @@ class User extends Authenticatable implements PasskeyUser
'two_factor_confirmed_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime',
]; ];
} }
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
}
} }
@@ -0,0 +1,72 @@
<?php
namespace App\Notifications;
use App\Models\Chat;
use App\Models\Message;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class NewActivityNotification extends Notification
{
use Queueable;
public $activity;
/**
* Create a new notification instance.
*/
public function __construct($activity)
{
$this->activity = $activity;
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
return [WebPushChannel::class];
}
/**
* Get the web push representation of the notification.
*/
public function toWebPush(object $notifiable): WebPushMessage
{
$result = (new WebPushMessage)
->title('New Activity')
->icon('/apple-touch-icon.png')
->body($this->activity['content'])
->action('View', 'view')
->data(['url' => $this->activity['url']]);
switch (get_class($this->activity)) {
case Message::class:
/** @var Chat $chat */
$chat = $this->activity->chat;
$result->data(['url' => $chat->subjectUrl]);
break;
}
return $result;
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
//
];
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ class DynamicPolicy
*/ */
public function update(User $user, Dynamic $dynamic): bool public function update(User $user, Dynamic $dynamic): bool
{ {
return false; return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
} }
/** /**
+9 -1
View File
@@ -37,7 +37,15 @@ class LedgerPolicy
*/ */
public function update(User $user, Ledger $ledger): bool public function update(User $user, Ledger $ledger): bool
{ {
return false; return $user->can('update', $ledger->dynamic);
}
/**
* Determine whether the user can close the model.
*/
public function close(User $user, Ledger $ledger): bool
{
return $user->can('update', $ledger->dynamic);
} }
/** /**
+26 -3
View File
@@ -2,18 +2,41 @@
namespace App\Policies; namespace App\Policies;
use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\User; use App\Models\User;
class MutationPolicy class MutationPolicy
{ {
/** /**
* Determine whether the user can view the mutation. * Determine whether the user can create mutations.
*/ */
public function view(User $user, Mutation $mutation): bool public function create(User $user, Ledger $ledger): bool
{ {
$dynamic = $mutation->ledger->dynamic; $dynamic = $ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->exists(); return $dynamic->participants()->where('user_id', $user->id)->exists();
} }
/**
* Determine whether the user can update the mutation.
*/
public function update(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
$isOwner = $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
return $isOwner && $mutation->status === 'pending';
}
/**
* Determine whether the user can void the mutation.
*/
public function void(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
$isOwner = $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
return $isOwner && $mutation->status !== 'voided';
}
} }
+2
View File
@@ -3,6 +3,7 @@
namespace App\Providers; namespace App\Providers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -23,6 +24,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
JsonResource::withoutWrapping();
$this->configureDefaults(); $this->configureDefaults();
} }
+200
View File
@@ -0,0 +1,200 @@
<?php
namespace App\Services;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Message;
use App\Models\ReadCursor;
use App\Models\User;
use App\Notifications\NewActivityNotification;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
class ActivityService
{
/**
* Update the read cursor for a user on a specific entity.
*/
public function updateCursor(User $user, $entity): void
{
ReadCursor::updateOrCreate(
[
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
],
[
'read_at' => Carbon::now(),
]
);
}
/**
* Get the read cursor timestamp for a user on a specific entity.
*/
public function getCursorReadAt(User $user, $entity): CarbonInterface
{
$cursor = ReadCursor::where([
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
])->first();
// If no cursor exists, default to epoch (all activities are unread)
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
}
public function createMessage($chat, $user, $content, $subject = null)
{
$message = $chat->messages()->create([
'user_id' => $user ? $user->id : null,
'content' => $content,
'subject_id' => $subject ? $subject->id : null,
'subject_type' => $subject ? get_class($subject) : null,
]);
$this->notify($message);
return $message;
}
public function createMutation($ledger, $user, $type, $amount, $description, $status)
{
$mutation = $ledger->mutations()->create([
'user_id' => $user->id,
'type' => $type,
'amount' => $amount,
'description' => $description,
'status' => $status,
]);
return $mutation;
}
public function notify(Message $message)
{
$dynamic = $message->chat->chatable;
if ($dynamic instanceof Dynamic) {
$participants = $dynamic->participants;
foreach ($participants as $participant) {
if ($message->user_id !== $participant->id) {
$participant->notify(new NewActivityNotification($message));
}
}
}
}
/**
* Retrieve all activities for a given entity.
*/
public function getActivitiesForDynamic(Dynamic $dynamic): array
{
$participants = $dynamic->participants()->withPivot('display_name')->get();
$participantsMap = $participants->reduce(function ($acc, $p) {
$acc[$p->id] = $p->pivot->display_name ?? $p->name;
return $acc;
}, []);
$messages = Message::where('chat_id', $dynamic->chat->id)
->with(['user', 'subject'])
->latest()
->get();
return $messages->map(function ($message) use ($participantsMap) {
$messageData = $message->toArray();
$messageData['url'] = $this->getUrlForMessage($message);
// Resolve <user:id> placeholders to actual names/display names
$messageData['content'] = preg_replace_callback('/<user:(\d+)>/', function ($matches) use ($participantsMap) {
$userId = $matches[1];
return $participantsMap[$userId] ?? "User #{$userId}";
}, $message->content);
return $messageData;
})->all();
}
/**
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
*/
public function getUnreadDynamicsGrouped(User $user): array
{
$groupedDynamics = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
foreach ($participatingDynamics as $dynamic) {
$readAt = $this->getCursorReadAt($user, $dynamic);
$activities = $this->getActivitiesForDynamic($dynamic);
$this->partitionAndGroupActivities($activities, $readAt, $dynamic, $groupedDynamics);
}
return $groupedDynamics;
}
/**
* Partition activities into read and unread, and construct the grouped entity metadata.
*/
private function partitionAndGroupActivities(array $activities, CarbonInterface $readAt, Dynamic $dynamic, array &$groupedDynamics): void
{
$alreadyRead = [];
$unread = [];
foreach ($activities as $act) {
if (Carbon::parse($act['created_at'])->gt($readAt)) {
$unread[] = $act;
} else {
$alreadyRead[] = $act;
}
}
if (! empty($unread)) {
$context = array_slice($alreadyRead, 0, 2);
$groupedDynamics[] = [
'id' => $dynamic->id,
'name' => $dynamic->name,
'url' => route('dynamics.show', $dynamic->uuid),
'unread_count' => count($unread),
'context_activities' => $context,
'new_activities' => array_reverse($unread),
];
}
}
private function getUrlForEntity($entity): string
{
if ($entity instanceof Dynamic) {
return route('dynamics.show', $entity->uuid);
}
if ($entity instanceof Ledger) {
return route('dynamics.ledgers.show', [$entity->dynamic->uuid, $entity->uuid]);
}
if ($entity instanceof Message) {
$subject = $entity->subject;
return $this->getUrlForEntity($subject);
}
return '';
}
private function getUrlForMessage(Message $message): string
{
if ($message->subject) {
return $this->getUrlForEntity($message->subject);
}
if ($message->chat->chatable) {
return $this->getUrlForEntity($message->chat->chatable);
}
return '';
}
}
+6 -1
View File
@@ -18,11 +18,16 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware
->web(append: [
HandleAppearance::class, HandleAppearance::class,
HandleInertiaRequests::class, HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
])
->trustProxies(at: [
'172.16.0.0/12',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+4 -2
View File
@@ -3,11 +3,13 @@
use App\Providers\AppServiceProvider; use App\Providers\AppServiceProvider;
use App\Providers\AuthServiceProvider; use App\Providers\AuthServiceProvider;
use App\Providers\FortifyServiceProvider; use App\Providers\FortifyServiceProvider;
use Illuminate\Broadcasting\BroadcastServiceProvider;
use Tighten\Ziggy\ZiggyServiceProvider;
return [ return [
AppServiceProvider::class, AppServiceProvider::class,
AuthServiceProvider::class, AuthServiceProvider::class,
FortifyServiceProvider::class, FortifyServiceProvider::class,
\Illuminate\Broadcasting\BroadcastServiceProvider::class, BroadcastServiceProvider::class,
\Tighten\Ziggy\ZiggyServiceProvider::class, ZiggyServiceProvider::class,
]; ];
+1
View File
@@ -11,6 +11,7 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"inertiajs/inertia-laravel": "^3.0", "inertiajs/inertia-laravel": "^3.0",
"laravel-notification-channels/webpush": "^11.0",
"laravel/chisel": "^0.1.0", "laravel/chisel": "^0.1.0",
"laravel/fortify": "^1.37.2", "laravel/fortify": "^1.37.2",
"laravel/framework": "^13.7", "laravel/framework": "^13.7",
Generated
+373 -2
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4f6fe33dc680e6446bd6318d5bdd9ec9", "content-hash": "f4c79dcf0b7f9f54487404715d1085c1",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -1461,6 +1461,72 @@
}, },
"time": "2026-04-30T15:30:29+00:00" "time": "2026-04-30T15:30:29+00:00"
}, },
{
"name": "laravel-notification-channels/webpush",
"version": "11.0.0",
"source": {
"type": "git",
"url": "https://github.com/laravel-notification-channels/webpush.git",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9",
"reference": "85b577e64459a9df06a24062e2b300abbaa99fa9",
"shasum": ""
},
"require": {
"illuminate/notifications": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"minishlink/web-push": "^10.0.1",
"php": "^8.2"
},
"require-dev": {
"larastan/larastan": "^3.1",
"laravel/pint": "^1.25",
"mockery/mockery": "^1.0",
"orchestra/testbench": "^9.2|^10.0|^11.0",
"phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11",
"rector/rector": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"NotificationChannels\\WebPush\\WebPushServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"NotificationChannels\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Cretu Eusebiu",
"email": "me@cretueusebiu.com",
"homepage": "http://cretueusebiu.com",
"role": "Developer"
},
{
"name": "Joost de Bruijn",
"email": "joost@aqualabs.nl",
"role": "Maintainer"
}
],
"description": "Web Push Notifications driver for Laravel.",
"homepage": "https://github.com/laravel-notification-channels/webpush",
"support": {
"issues": "https://github.com/laravel-notification-channels/webpush/issues",
"source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0"
},
"time": "2026-05-24T13:22:27+00:00"
},
{ {
"name": "laravel/chisel", "name": "laravel/chisel",
"version": "v0.1.1", "version": "v0.1.1",
@@ -2759,6 +2825,77 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{
"name": "minishlink/web-push",
"version": "v10.1.0",
"source": {
"type": "git",
"url": "https://github.com/web-push-libs/web-push-php.git",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^7.9.2",
"php": ">=8.2",
"psr/log": "^2.0|^3.0",
"spomky-labs/base64url": "^2.0.4",
"symfony/polyfill-php83": "^1.33",
"web-token/jwt-library": "^3.4.9|^4.0.6"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^v3.92.2",
"phpstan/phpstan": "^2.1.33",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.46|^12.5.2",
"symfony/polyfill-iconv": "^1.33"
},
"suggest": {
"ext-bcmath": "Optional for performance.",
"ext-gmp": "Optional for performance."
},
"type": "library",
"autoload": {
"psr-4": {
"Minishlink\\WebPush\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Louis Lagrange",
"email": "lagrange.louis@gmail.com",
"homepage": "https://github.com/Minishlink"
}
],
"description": "Web Push library for PHP",
"homepage": "https://github.com/web-push-libs/web-push-php",
"keywords": [
"Push API",
"WebPush",
"notifications",
"push",
"web"
],
"support": {
"issues": "https://github.com/web-push-libs/web-push-php/issues",
"source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0"
},
"time": "2026-05-28T09:37:37+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -5027,6 +5164,71 @@
], ],
"time": "2024-06-11T12:45:25+00:00" "time": "2024-06-11T12:45:25+00:00"
}, },
{
"name": "spomky-labs/base64url",
"version": "v2.0.4",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/base64url.git",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"require-dev": {
"phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^0.11|^0.12",
"phpstan/phpstan-beberlei-assert": "^0.11|^0.12",
"phpstan/phpstan-deprecation-rules": "^0.11|^0.12",
"phpstan/phpstan-phpunit": "^0.11|^0.12",
"phpstan/phpstan-strict-rules": "^0.11|^0.12"
},
"type": "library",
"autoload": {
"psr-4": {
"Base64Url\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky-Labs/base64url/contributors"
}
],
"description": "Base 64 URL Safe Encoding/Decoding PHP Library",
"homepage": "https://github.com/Spomky-Labs/base64url",
"keywords": [
"base64",
"rfc4648",
"safe",
"url"
],
"support": {
"issues": "https://github.com/Spomky-Labs/base64url/issues",
"source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2020-11-03T09:10:25+00:00"
},
{ {
"name": "spomky-labs/cbor-php", "name": "spomky-labs/cbor-php",
"version": "3.2.3", "version": "3.2.3",
@@ -6702,6 +6904,86 @@
], ],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{
"name": "symfony/polyfill-php83",
"version": "v1.38.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Php83\\": ""
},
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-27T06:51:48+00:00"
},
{ {
"name": "symfony/polyfill-php84", "name": "symfony/polyfill-php84",
"version": "v1.38.1", "version": "v1.38.1",
@@ -8475,6 +8757,95 @@
], ],
"time": "2026-05-31T15:00:08+00:00" "time": "2026-05-31T15:00:08+00:00"
}, },
{
"name": "web-token/jwt-library",
"version": "4.1.7",
"source": {
"type": "git",
"url": "https://github.com/web-token/jwt-library.git",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d",
"shasum": ""
},
"require": {
"brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
"php": ">=8.2",
"psr/clock": "^1.0",
"spomky-labs/pki-framework": "^1.2.1"
},
"conflict": {
"spomky-labs/jose": "*"
},
"suggest": {
"ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance",
"ext-gmp": "GMP or BCMath is highly recommended to improve the library performance",
"ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)",
"ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys",
"spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)",
"symfony/console": "Needed to use console commands",
"symfony/http-client": "To enable JKU/X5U support."
},
"type": "library",
"autoload": {
"psr-4": {
"Jose\\Component\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Florent Morselli",
"homepage": "https://github.com/Spomky"
},
{
"name": "All contributors",
"homepage": "https://github.com/web-token/jwt-framework/contributors"
}
],
"description": "JWT library",
"homepage": "https://github.com/web-token",
"keywords": [
"JOSE",
"JWE",
"JWK",
"JWKSet",
"JWS",
"Jot",
"RFC7515",
"RFC7516",
"RFC7517",
"RFC7518",
"RFC7519",
"RFC7520",
"bundle",
"jwa",
"jwt",
"symfony"
],
"support": {
"issues": "https://github.com/web-token/jwt-library/issues",
"source": "https://github.com/web-token/jwt-library/tree/4.1.7"
},
"funding": [
{
"url": "https://github.com/Spomky",
"type": "github"
},
{
"url": "https://www.patreon.com/FlorentMorselli",
"type": "patreon"
}
],
"time": "2026-06-06T18:12:39+00:00"
},
{ {
"name": "webmozart/assert", "name": "webmozart/assert",
"version": "2.4.0", "version": "2.4.0",
@@ -12251,5 +12622,5 @@
"php": "^8.4" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.6.0" "plugin-api-version": "2.9.0"
} }
+1 -1
View File
@@ -23,7 +23,7 @@ class DynamicFactory extends Factory
'Obsidian Household Agreement', 'Obsidian Household Agreement',
'Crimson Castle Protocol', 'Crimson Castle Protocol',
'Dungeon Master-Sub Board', 'Dungeon Master-Sub Board',
'Coffee Club Ledger' 'Coffee Club Ledger',
]), ]),
'rules' => "1. All rules must be strictly adhered to.\n2. Scores must be updated after every task.\n3. Disputed scores can be discussed in the dedicated chat.", 'rules' => "1. All rules must be strictly adhered to.\n2. Scores must be updated after every task.\n3. Disputed scores can be discussed in the dedicated chat.",
]; ];
+3 -2
View File
@@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Ledger;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@@ -26,10 +26,11 @@ class LedgerFactory extends Factory
'Dungeon Cleaning', 'Dungeon Cleaning',
'Silence Protocol', 'Silence Protocol',
'Task Completion', 'Task Completion',
'Tribute Points' 'Tribute Points',
]), ]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.', 'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'score' => 0, 'score' => 0,
'alignment' => 'neutral',
]; ];
} }
} }
+1 -1
View File
@@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Message;
use App\Models\Chat; use App\Models\Chat;
use App\Models\Message;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
+1 -1
View File
@@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Mutation;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@@ -18,7 +18,7 @@ return new class extends Migration
$table->string('type'); $table->string('type');
$table->integer('amount'); $table->integer('amount');
$table->text('description')->nullable(); $table->text('description')->nullable();
$table->string('status')->default('pending'); $table->string('status')->default('pending'); // pending, approved, rejected, voided
$table->timestamps(); $table->timestamps();
}); });
} }
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->string('alignment')->default('neutral')->after('rules');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('alignment');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('read_cursors', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('cursorable');
$table->timestamp('read_at');
$table->timestamps();
$table->unique(['user_id', 'cursorable_id', 'cursorable_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('read_cursors');
}
};
@@ -4,8 +4,10 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
return new class extends Migration { return new class extends Migration
public function up(): void { {
public function up(): void
{
Schema::create('media', function (Blueprint $table) { Schema::create('media', function (Blueprint $table) {
$table->id(); $table->id();
$table->morphs('mediable'); // mediable_type, mediable_id $table->morphs('mediable'); // mediable_type, mediable_id
@@ -16,7 +18,8 @@ return new class extends Migration {
}); });
} }
public function down(): void { public function down(): void
{
Schema::dropIfExists('media'); Schema::dropIfExists('media');
} }
}; };
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('dynamic_invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
$table->string('email');
$table->string('role');
$table->string('token')->unique();
$table->timestamp('expires_at');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('dynamic_invitations');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->change();
$table->nullableMorphs('subject');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->foreignId('user_id')->nullable(false)->change();
$table->dropMorphs('subject');
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('predefined_mutations', function (Blueprint $table) {
$table->id();
$table->foreignId('ledger_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->integer('amount');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('predefined_mutations');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->foreignId('predefined_mutation_id')->nullable()->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->dropForeign(['predefined_mutation_id']);
$table->dropColumn('predefined_mutation_id');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->string('display_name')->nullable()->after('role');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('participants', function (Blueprint $table) {
$table->dropColumn('display_name');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->string('status')->default('open');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('status');
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->string('status')->default('pending')->change();
});
}
};
@@ -0,0 +1,40 @@
<?php
use App\Models\Dynamic;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Dynamic::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('dynamics', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('dynamics', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,40 @@
<?php
use App\Models\Ledger;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Ledger::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('ledgers', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,40 @@
<?php
use App\Models\Mutation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
Mutation::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,40 @@
<?php
use App\Models\PredefinedMutation;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
PredefinedMutation::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,40 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->after('id')->nullable();
});
// Populate existing rows with UUIDs
User::all()->each(function ($model) {
$model->uuid = Str::uuid();
$model->save();
});
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->unique()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::connection(config('webpush.database_connection'))->create(config('webpush.table_name'), function (Blueprint $table) {
$table->bigIncrements('id');
$table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx');
$table->string('endpoint', 500)->unique();
$table->string('public_key')->nullable();
$table->string('auth_token')->nullable();
$table->string('content_encoding')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name'));
}
};
+17 -13
View File
@@ -2,14 +2,14 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Models\Mutation;
use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@@ -56,9 +56,9 @@ class DatabaseSeeder extends Seeder
]); ]);
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant) // Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
$velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner', 'display_name' => 'The Master']);
$velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant']); $velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant', 'display_name' => 'Bitch Boi']);
// Chat has been auto-created by the booted hook on Dynamic // Chat has been auto-created by the booted hook on Dynamic
$velvetChat = $velvetSanctuary->chat; $velvetChat = $velvetSanctuary->chat;
@@ -94,6 +94,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Curfew Compliance', 'name' => 'Curfew Compliance',
'rules' => 'Must be in bed and checked in by 11:00 PM.', 'rules' => 'Must be in bed and checked in by 11:00 PM.',
'score' => 35, 'score' => 35,
'alignment' => 'positive',
]); ]);
$cleaningLedger = Ledger::create([ $cleaningLedger = Ledger::create([
@@ -101,13 +102,15 @@ class DatabaseSeeder extends Seeder
'name' => 'Dungeon Cleaning', 'name' => 'Dungeon Cleaning',
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.', 'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
'score' => 45, 'score' => 45,
'alignment' => 'neutral',
]); ]);
$etiquetteLedger = Ledger::create([ $etiquetteLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id, 'dynamic_id' => $velvetSanctuary->id,
'name' => 'Protocol & Etiquette', 'name' => 'Protocol & Etiquette',
'rules' => 'Address others by correct titles. -5 per infraction.', 'rules' => 'Address others by correct titles. +5 per infraction.',
'score' => -15, 'score' => 15,
'alignment' => 'negative',
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
@@ -168,7 +171,7 @@ class DatabaseSeeder extends Seeder
Mutation::create([ Mutation::create([
'ledger_id' => $cleaningLedger->id, 'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id, 'user_id' => $alice->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => -10, 'amount' => -10,
'description' => 'Left keys in the locks unmonitored', 'description' => 'Left keys in the locks unmonitored',
@@ -211,7 +214,7 @@ class DatabaseSeeder extends Seeder
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => -5, 'amount' => 5,
'description' => 'Interrupted Domina Alice during daily instructions', 'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved', 'status' => 'approved',
]); ]);
@@ -220,7 +223,7 @@ class DatabaseSeeder extends Seeder
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => -10, 'amount' => 10,
'description' => 'Forgot correct posture during morning roll call', 'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved', 'status' => 'approved',
]); ]);
@@ -229,7 +232,7 @@ class DatabaseSeeder extends Seeder
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => -5, 'amount' => 5,
'description' => 'Spoke out of turn in general chat', 'description' => 'Spoke out of turn in general chat',
'status' => 'approved', 'status' => 'approved',
]); ]);
@@ -238,12 +241,11 @@ class DatabaseSeeder extends Seeder
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id, 'user_id' => $bob->id,
'type' => 'reward', 'type' => 'reward',
'amount' => 5, 'amount' => -5,
'description' => 'Excellent reciting of the house codes', 'description' => 'Excellent reciting of the house codes',
'status' => 'approved', 'status' => 'approved',
]); ]);
// ---------------------------------------------------- // ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement // 3. Seed Dynamic 2: Obsidian Household Agreement
// ---------------------------------------------------- // ----------------------------------------------------
@@ -288,6 +290,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Kitchen Chores', 'name' => 'Kitchen Chores',
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.', 'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
'score' => 40, 'score' => 40,
'alignment' => 'positive',
]); ]);
$coffeeLedger = Ledger::create([ $coffeeLedger = Ledger::create([
@@ -295,6 +298,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Coffee Machine Maintenance', 'name' => 'Coffee Machine Maintenance',
'rules' => ' refill beans and descale monthly.', 'rules' => ' refill beans and descale monthly.',
'score' => 10, 'score' => 10,
'alignment' => 'neutral',
]); ]);
// Seed Chores Mutations // Seed Chores Mutations
+125 -101
View File
@@ -636,9 +636,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/vue": { "node_modules/@lucide/vue": {
"version": "1.18.0", "version": "1.21.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz",
"integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==", "integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"vue": ">=3.0.1" "vue": ">=3.0.1"
@@ -1297,9 +1297,9 @@
} }
}, },
"node_modules/@tanstack/virtual-core": { "node_modules/@tanstack/virtual-core": {
"version": "3.17.0", "version": "3.17.1",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1307,12 +1307,12 @@
} }
}, },
"node_modules/@tanstack/vue-virtual": { "node_modules/@tanstack/vue-virtual": {
"version": "3.13.28", "version": "3.13.29",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.28.tgz", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.29.tgz",
"integrity": "sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==", "integrity": "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/virtual-core": "3.17.0" "@tanstack/virtual-core": "3.17.1"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1354,9 +1354,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.21", "version": "22.20.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -1370,17 +1370,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz",
"integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/type-utils": "8.61.1",
"@typescript-eslint/utils": "8.61.0", "@typescript-eslint/utils": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
@@ -1393,7 +1393,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.61.0", "@typescript-eslint/parser": "^8.61.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
@@ -1409,16 +1409,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1434,14 +1434,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/tsconfig-utils": "^8.61.1",
"@typescript-eslint/types": "^8.61.0", "@typescript-eslint/types": "^8.61.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1456,14 +1456,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0" "@typescript-eslint/visitor-keys": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1474,9 +1474,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1491,15 +1491,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz",
"integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.0", "@typescript-eslint/utils": "8.61.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.5.0" "ts-api-utils": "^2.5.0"
}, },
@@ -1516,9 +1516,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1530,16 +1530,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/project-service": "8.61.1",
"@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/visitor-keys": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -1558,16 +1558,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz",
"integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/scope-manager": "8.61.1",
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0" "@typescript-eslint/typescript-estree": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1582,13 +1582,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.0", "@typescript-eslint/types": "8.61.1",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -2033,9 +2033,9 @@
} }
}, },
"node_modules/@vue/eslint-config-typescript": { "node_modules/@vue/eslint-config-typescript": {
"version": "14.8.0", "version": "14.9.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.8.0.tgz", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz",
"integrity": "sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==", "integrity": "sha512-E3j9hDlfVf10F30MRcLTPY2IIhWIx1nsvkVukk14kTcuA+oBVot9zsP1hzsO+PAMDxV3Fd9FimBJtUBNBL5KFA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2044,6 +2044,9 @@
"typescript-eslint": "^8.60.0", "typescript-eslint": "^8.60.0",
"vue-eslint-parser": "^10.4.0" "vue-eslint-parser": "^10.4.0"
}, },
"bin": {
"vue-eslint-config-typescript": "dist/bin.js"
},
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -2639,15 +2642,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.1", "version": "9.2.3",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", "integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chalk": "4.1.2", "chalk": "4.1.2",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"shell-quote": "1.8.3", "shell-quote": "1.8.4",
"supports-color": "8.1.1", "supports-color": "8.1.1",
"tree-kill": "1.2.2", "tree-kill": "1.2.2",
"yargs": "17.7.2" "yargs": "17.7.2"
@@ -2964,6 +2967,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-abstract-get": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
"integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.2",
"is-callable": "^1.2.7",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -3027,15 +3049,17 @@
} }
}, },
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.0", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz",
"integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-abstract-get": "^1.0.0",
"es-errors": "^1.3.0",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-date-object": "^1.0.5", "is-date-object": "^1.1.0",
"is-symbol": "^1.0.4" "is-symbol": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@@ -3045,9 +3069,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.47.1", "version": "1.48.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz",
"integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==", "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"docs", "docs",
@@ -5090,9 +5114,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -5687,9 +5711,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.9.10", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.0.tgz",
"integrity": "sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==", "integrity": "sha512-HIUVfSBM/AyGkcUI7aiOxxMc4N+0UD2ZEun8dcrT0H4fveotEoeDdvzyZu97eeEvEa1H9oGHoOpApkfxlgnC7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@@ -5937,9 +5961,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -6022,9 +6046,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.3", "version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6519,16 +6543,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.61.0", "version": "8.61.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz",
"integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.0", "@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.1",
"@typescript-eslint/utils": "8.61.0" "@typescript-eslint/utils": "8.61.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+38
View File
@@ -0,0 +1,38 @@
self.addEventListener('push', function (event) {
let data = {};
try {
data = event.data?.json() ?? {};
} catch (e) {
data = { title: 'Notification', body: event.data?.text() ?? '' };
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
data: data.data,
actions: data.actions,
}),
);
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
const url = event.notification.data?.url ?? '/';
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(function (clientList) {
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
+2
View File
@@ -2,6 +2,8 @@
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './components.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
+18
View File
@@ -0,0 +1,18 @@
/* ==========================================================================
BEM UI Components Index Manifest (Tailwind CSS v4)
========================================================================== */
@import './components/heading.css';
@import './components/input-error.css';
@import './components/text-link.css';
@import './components/app-logo.css';
@import './components/user-info.css';
@import './components/sidebar-header.css';
@import './components/app-content.css';
@import './components/appearance-tabs.css';
@import './components/password-input.css';
@import './components/auth-layout.css';
@import './components/chat.css';
@import "./components/lightbox.css";
@import "./components/invite-form.css";
/*@import "./components/display-name-form.css";*/
+5
View File
@@ -0,0 +1,5 @@
/* 7. AppContent Layout Component */
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4;
border-radius: var(--radius);
}
+20
View File
@@ -0,0 +1,20 @@
/* 4. AppLogo Component */
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
color: var(--sidebar-primary-foreground);
.app-logo__icon {
@apply size-5 fill-current;
}
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
color: var(--foreground);
}
}
@@ -0,0 +1,35 @@
/* 8. AppearanceTabs Component */
.appearance-tabs {
@apply inline-flex gap-1 p-1;
border-radius: var(--radius);
background-color: var(--muted);
.appearance-tabs__tab {
@apply flex cursor-pointer items-center px-3.5 py-1.5 transition-colors;
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
background-color: rgba(0, 0, 0, 0.05);
}
&.appearance-tabs__tab--active {
background-color: var(--card);
color: var(--card-foreground);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
background-color: var(--card);
}
}
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
}
+46
View File
@@ -0,0 +1,46 @@
/* 10. AuthSimpleLayout Component */
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10;
background-color: var(--background);
.auth-layout__container {
@apply w-full max-w-sm;
.auth-layout__inner {
@apply flex flex-col gap-8;
.auth-layout__header {
@apply flex flex-col items-center gap-4;
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
.auth-layout__logo {
@apply size-9 fill-current;
color: var(--sidebar-primary-foreground);
}
}
}
.auth-layout__title-box {
@apply space-y-2 text-center;
.auth-layout__title {
@apply text-xl font-medium;
color: var(--foreground);
}
.auth-layout__description {
@apply text-center text-sm;
color: var(--muted-foreground);
}
}
}
}
}
}
+224
View File
@@ -0,0 +1,224 @@
/* 11. Chat Component */
.c-chat {
@apply mt-8;
.c-chat__title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-chat__list {
@apply mt-4 flex flex-col gap-3;
.c-chat__load-more {
@apply mb-4 text-center;
.c-chat__load-more-btn {
@apply text-sm font-semibold;
color: var(--primary);
&:hover {
text-decoration: underline;
}
}
}
.c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border);
&.c-chat__message--system {
@apply w-full self-center border-0 bg-transparent p-0 shadow-none;
}
&.c-chat__message--own {
@apply self-end rounded-br-none text-right;
max-width: 80%;
background-color: var(--primary);
border-color: var(--primary);
.c-chat__message-header {
@apply flex-row-reverse;
}
.c-chat__message-author {
color: var(--primary-foreground);
}
.c-chat__message-time {
color: var(--primary-foreground);
opacity: 0.7;
}
.c-chat__message-text {
color: var(--primary-foreground);
}
}
&.c-chat__message--other {
@apply self-start rounded-bl-none;
max-width: 80%;
background-color: var(--muted);
border-color: var(--border);
.c-chat__message-author {
color: var(--foreground);
}
.c-chat__message-time {
color: var(--muted-foreground);
}
.c-chat__message-text {
color: var(--foreground);
}
}
.c-chat__system-inner {
@apply mx-auto flex w-fit items-center justify-center gap-2 px-3 py-1.5 text-xs;
background-color: var(--muted);
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
.c-chat__system-icon {
@apply size-3.5 shrink-0;
color: var(--muted-foreground);
}
.c-chat__system-text {
@apply font-medium;
}
.c-chat__system-time {
@apply shrink-0 text-[10px];
color: var(--muted-foreground);
}
}
.c-chat__message-header {
@apply flex justify-between;
.c-chat__message-author {
@apply font-semibold;
/*color: var(--foreground);*/
}
.c-chat__message-time {
@apply text-xs;
color: var(--muted-foreground);
}
}
.c-chat__message-text {
@apply mt-2 text-sm;
color: var(--foreground);
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden bg-black;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
}
}
}
}
.c-chat__empty {
color: var(--muted-foreground);
}
.c-chat__form {
@apply mt-6 space-y-6;
.c-chat__form-group {
@apply space-y-2;
.c-chat__label {
@apply block text-sm font-medium;
color: var(--foreground);
}
.c-chat__textarea {
@apply mt-1 block w-full shadow-sm;
border-radius: var(--radius);
border: 1px solid var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-chat__error {
@apply text-sm;
color: var(--destructive);
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs transition-colors;
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
}
.c-chat__attach-icon {
@apply size-3.5;
}
}
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 p-1.5 pr-8 text-xs;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
background-color: var(--muted);
.c-chat__preview-name {
@apply max-w-[150px] truncate;
color: var(--foreground);
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px];
color: var(--muted-foreground);
&:hover {
color: var(--destructive);
}
}
}
}
}
.c-chat__submit-box {
@apply flex items-center gap-4;
.c-chat__button {
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
border-radius: var(--radius);
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
}
}
}
.c-chat__user-link { @apply font-semibold text-blue-500 hover:underline; }
+22
View File
@@ -0,0 +1,22 @@
/* 1. Heading Component */
.c-heading {
@apply mb-8 space-y-0.5;
&.c-heading--small {
@apply mb-0 space-y-0;
.c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
color: var(--foreground);
}
.c-heading__description {
@apply text-sm;
color: var(--muted-foreground);
}
}
+5
View File
@@ -0,0 +1,5 @@
/* 2. InputError Component */
.c-input-error__message {
@apply text-sm;
color: var(--destructive);
}
+67
View File
@@ -0,0 +1,67 @@
/* 13. InviteForm Component */
.c-invite-form {
@apply mt-8;
.c-invite-form__card {
@apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
.c-invite-form__body {
@apply p-6;
color: var(--foreground);
.c-invite-form__title {
@apply text-lg font-medium;
}
.c-invite-form__form {
@apply mt-6 space-y-6;
.c-invite-form__field {
@apply block;
.c-invite-form__label {
@apply block text-sm font-medium;
color: var(--foreground);
}
.c-invite-form__input {
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-invite-form__select {
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-invite-form__error {
@apply text-sm;
color: var(--destructive);
}
}
.c-invite-form__actions {
@apply flex items-center gap-4;
.c-invite-form__submit-btn {
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
border-radius: var(--radius);
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
}
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
/* 12. Reusable Lightbox Component */
.c-lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
background-color: rgba(0, 0, 0, 0.95);
.c-lightbox__close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200;
&:hover {
color: var(--destructive);
}
}
.c-lightbox__content {
@apply max-h-full max-w-full;
.c-lightbox__image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-lightbox__video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
}
}
@@ -0,0 +1,21 @@
/* 9. PasswordInput Component */
.password-input {
@apply relative;
.password-input__field {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none;
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
}
}
.password-input__icon {
@apply size-4;
}
}
@@ -0,0 +1,14 @@
/* 6. AppSidebarHeader Component */
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear md:px-4;
border-bottom: 1px solid var(--sidebar-border);
background-color: var(--sidebar-background);
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
.group-has-data-[collapsible='icon']/sidebar-wrapper:h-12 .sidebar-header {
@apply h-12;
}
+10
View File
@@ -0,0 +1,10 @@
/* 3. TextLink Component */
.c-text-link {
@apply underline underline-offset-4 transition-colors duration-300 ease-out;
color: var(--foreground);
text-decoration-color: var(--border);
&:hover {
text-decoration-color: var(--foreground);
}
}
+19
View File
@@ -0,0 +1,19 @@
/* 5. UserInfo Component */
.user-info__avatar {
@apply h-8 w-8 overflow-hidden;
border-radius: var(--radius);
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
.user-info__name {
@apply truncate font-medium;
color: var(--foreground);
}
.user-info__email {
@apply truncate text-xs;
color: var(--muted-foreground);
}
}
+7 -1
View File
@@ -19,7 +19,9 @@ configureEcho({
forceTLS: false, forceTLS: false,
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
(window as any).echoConfigured = true; if (typeof window !== 'undefined') {
(window as any).echoConfigured = true;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -47,3 +49,7 @@ initializeTheme();
// This will listen for flash toast data from the server... // This will listen for flash toast data from the server...
initializeFlashToast(); initializeFlashToast();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
+89 -32
View File
@@ -3,8 +3,8 @@ import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const props = defineProps<{ const props = defineProps<{
dynamicId: number; dynamicId: string;
ledgerId: number; ledgerId: string;
}>(); }>();
const form = useForm({ const form = useForm({
@@ -41,55 +41,48 @@ function submit() {
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-add-mutation-form">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 class="c-add-mutation-form__title">Add Mutation</h4>
Add Mutation <form @submit.prevent="submit" class="c-add-mutation-form__form">
</h4> <div class="c-add-mutation-form__field">
<form <label for="amount" class="c-add-mutation-form__label"
@submit.prevent="submit"
class="mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div>
<label
for="amount"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Amount</label >Amount</label
> >
<input <input
v-model="form.amount" v-model="form.amount"
id="amount" id="amount"
type="number" type="number"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-add-mutation-form__input"
/> />
<div v-if="form.errors.amount" class="text-sm text-red-600"> <div
v-if="form.errors.amount"
class="c-add-mutation-form__error"
>
{{ form.errors.amount }} {{ form.errors.amount }}
</div> </div>
</div> </div>
<div> <div class="c-add-mutation-form__field">
<label <label for="description" class="c-add-mutation-form__label"
for="description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Description</label >Description</label
> >
<textarea <textarea
v-model="form.description" v-model="form.description"
id="description" id="description"
rows="4" rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-add-mutation-form__textarea"
></textarea> ></textarea>
<div <div
v-if="form.errors.description" v-if="form.errors.description"
class="text-sm text-red-600" class="c-add-mutation-form__error"
> >
{{ form.errors.description }} {{ form.errors.description }}
</div> </div>
</div> </div>
<!-- Media Uploads for Mutations --> <!-- Media Uploads for Mutations -->
<div> <div class="c-add-mutation-form__field">
<label <label class="c-add-mutation-form__label"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Proof Media (Photos/Videos)</label >Attach Proof Media (Photos/Videos)</label
> >
<input <input
@@ -97,24 +90,24 @@ function submit() {
multiple multiple
accept="image/*,video/*" accept="image/*,video/*"
@change="handleMutationFileChange" @change="handleMutationFileChange"
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" class="c-add-mutation-form__file-input"
/> />
<div <div
v-if="form.media.length > 0" v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2" class="c-add-mutation-form__media-preview-list"
> >
<div <div
v-for="(file, index) in form.media" v-for="(file, index) in form.media"
:key="index" :key="index"
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800" class="c-add-mutation-form__media-preview-item"
> >
<span class="max-w-[150px] truncate">{{ <span class="c-add-mutation-form__media-preview-name">{{
file.name file.name
}}</span> }}</span>
<button <button
type="button" type="button"
@click="removeMutationFile(index)" @click="removeMutationFile(index)"
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500" class="c-add-mutation-form__media-preview-remove"
> >
</button> </button>
@@ -122,11 +115,11 @@ function submit() {
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="c-add-mutation-form__actions">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300" class="c-add-mutation-form__submit-btn"
> >
Add Mutation Add Mutation
</button> </button>
@@ -134,3 +127,67 @@ function submit() {
</form> </form>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-add-mutation-form {
@apply mt-8;
}
.c-add-mutation-form__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-add-mutation-form__form {
@apply mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-add-mutation-form__field {
@apply block;
}
.c-add-mutation-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-add-mutation-form__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__textarea {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__file-input {
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
}
.c-add-mutation-form__media-preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-add-mutation-form__media-preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-add-mutation-form__media-preview-name {
@apply max-w-[150px] truncate;
}
.c-add-mutation-form__media-preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-add-mutation-form__error {
@apply text-sm text-red-600;
}
.c-add-mutation-form__actions {
@apply flex items-center gap-4;
}
.c-add-mutation-form__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>
-8
View File
@@ -22,11 +22,3 @@ const className = computed(() => props.class);
<slot /> <slot />
</main> </main>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
}
</style>
+31
View File
@@ -33,6 +33,7 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { route } from 'ziggy-js';
import { getInitials } from '@/composables/useInitials'; import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
@@ -238,6 +239,36 @@ const rightNavItems: NavItem[] = [
</div> </div>
</div> </div>
<div
v-if="(page.props as any).unreadNotificationsCount > 0"
class="relative"
>
<Link
:href="route('dashboard')"
class="relative flex h-9 w-9 items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 opacity-80"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
>
{{ page.props.unreadNotificationsCount }}
</span>
</Link>
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger :as-child="true"> <DropdownMenuTrigger :as-child="true">
<Button <Button
+1 -21
View File
@@ -7,26 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="app-logo__icon" /> <AppLogoIcon class="app-logo__icon" />
</div> </div>
<div class="app-logo__text-container"> <div class="app-logo__text-container">
<span class="app-logo__text">Laravel Starter Kit</span> <span class="app-logo__text">Ledgerrz</span>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
}
.app-logo__icon {
@apply size-5 fill-current text-white dark:text-black;
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
}
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
}
</style>
+31 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue'; import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
import { onMounted, onUnmounted } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue'; import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
@@ -13,11 +14,30 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as dynamics } from '@/routes/dynamics'; import { index as dynamics } from '@/routes/dynamics';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const { isMobile, setOpenMobile } = useSidebar();
let unregister: () => void;
onMounted(() => {
unregister = router.on('navigate', () => {
if (isMobile.value) {
setOpenMobile(false);
}
});
});
onUnmounted(() => {
if (unregister) {
unregister();
}
});
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
]; ];
const footerNavItems: NavItem[] = [ const footerNavItems: NavItem[] = [
{ // {
title: 'Repository', // title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit', // href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2, // icon: FolderGit2,
}, // },
{ // {
title: 'Documentation', // title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue', // href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen, // icon: BookOpen,
}, // },
]; ];
</script> </script>
@@ -23,15 +23,3 @@ withDefaults(
</div> </div>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
</style>
@@ -27,27 +27,3 @@ const tabs = [
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.appearance-tabs {
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
}
.appearance-tabs__tab {
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
}
.appearance-tabs__tab--active {
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
</style>
+256 -166
View File
@@ -1,18 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm, usePage, router } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { Paperclip } from '@lucide/vue'; import { Paperclip, Info } from '@lucide/vue';
import { ref } from 'vue'; import { ref, computed, watch } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const props = defineProps<{ const props = withDefaults(
defineProps<{
chat: { chat: {
id: number; id: number;
messages: Array<{ messages?: Array<{
id: number; id: number;
user: { name: string }; user: { id: number; name: string } | null;
content: string; content: string;
created_at: string; created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{ media?: Array<{
id: number; id: number;
url: string; url: string;
@@ -21,7 +25,95 @@ const props = defineProps<{
}>; }>;
}>; }>;
}; };
}>(); participants?: Array<{
id: number;
name: string;
pivot?: {
display_name: string | null;
} | null;
}>;
dynamicId: string;
ledgerId?: string | null;
initialMessages?: {
data: Array<any>;
next_page_url?: string | null;
links?: {
next: string | null;
} | null;
current_page?: number;
meta?: {
current_page: number;
} | null;
} | null;
}>(),
{
participants: () => [],
initialMessages: null,
ledgerId: null,
}
);
const getNextPageUrl = (paginator: any) => {
return paginator?.links?.next ?? paginator?.next_page_url ?? null;
};
const getCurrentPage = (paginator: any) => {
return paginator?.meta?.current_page ?? paginator?.current_page ?? 1;
};
const messages = ref(
props.initialMessages
? props.initialMessages.data.slice().reverse()
: (props.chat.messages || []).slice()
);
const nextPageUrl = ref(getNextPageUrl(props.initialMessages));
const currentPageNum = ref(1);
watch(
() => props.initialMessages,
(newVal) => {
if (newVal && getCurrentPage(newVal) === 1) {
messages.value = newVal.data.slice().reverse();
nextPageUrl.value = getNextPageUrl(newVal);
currentPageNum.value = 1;
}
},
{ deep: true }
);
watch(
() => props.chat.messages,
(newVal) => {
if (!props.initialMessages && newVal) {
messages.value = newVal.slice();
}
},
{ deep: true }
);
function loadMoreMessages() {
if (!nextPageUrl.value) {
return;
}
currentPageNum.value++;
const apiRouteName = props.ledgerId ? 'dynamics.ledgers.messages' : 'dynamics.messages';
const apiParams = props.ledgerId ? [props.dynamicId, props.ledgerId] : [props.dynamicId];
const url = route(apiRouteName, [...apiParams, { page: currentPageNum.value }]);
fetch(url)
.then((res) => res.json())
.then((json) => {
const data = json?.data || [];
messages.value = [...data.slice().reverse(), ...messages.value];
nextPageUrl.value = getNextPageUrl(json);
})
.catch((err) => {
console.error('Failed to load older messages:', err);
currentPageNum.value--;
});
}
if (!echoIsConfigured()) { if (!echoIsConfigured()) {
configureEcho({ configureEcho({
@@ -47,9 +139,105 @@ const form = useForm({
}); });
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => { useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message); messages.value.push(e.message);
}); });
function formatTimestamp(isoString: string): { full: string; time: string } {
const date = new Date(isoString);
return {
full: date.toLocaleString(),
time: date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
}
const participantsById = computed(() => {
const list = props.participants || [];
return list.reduce(
(acc, p) => {
acc[p.id] = p;
return acc;
},
{} as Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>,
);
});
function parseMessageContent(message: {
content: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
}) {
let content = message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:(\d+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = participantsById.value[Number(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, Number(userId)]);
return `<a href="${url}" class="c-chat__user-link font-semibold text-blue-500 hover:underline">${
user.pivot?.display_name ?? user.name
}</a>`;
}
return `User #${userId}`;
});
// 2. Link subjects if found in the text
if (message.subject_id && message.subject_type) {
if (
message.subject_type === 'App\\Models\\Mutation' ||
message.subject_type === 'App\\Models\\Ledger'
) {
const ledgerId =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger_id
: message.subject?.id;
const ledgerName =
message.subject_type === 'App\\Models\\Mutation'
? message.subject?.ledger?.name
: message.subject?.name;
if (ledgerId && ledgerName) {
const ledgerUrl = route('dynamics.ledgers.show', [
props.dynamicId,
ledgerId,
]);
const escapedName = ledgerName.replace(
/[-\/\\^$*+?.()|[\]{}]/g,
'\\$&',
);
const nameRegex = new RegExp(`"${escapedName}"`, 'g');
content = content.replace(
nameRegex,
`"<a href="${ledgerUrl}" class="c-chat__subject-link font-semibold text-blue-500 hover:underline">${ledgerName}</a>"`,
);
}
}
}
return content;
}
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
@@ -64,8 +252,19 @@ function removeFile(index: number) {
form.media.splice(index, 1); form.media.splice(index, 1);
} }
const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number | null): boolean {
if (messageUserId === null) {
return false;
}
return currentUser.value && currentUser.value.id === messageUserId;
}
function submit() { function submit() {
form.post(route('chats.messages.store', props.chat.id), { form.post(route('chats.messages.store', props.chat.id), {
preserveScroll: true,
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
@@ -97,22 +296,37 @@ function closeLightbox() {
<div class="c-chat"> <div class="c-chat">
<h4 class="c-chat__title">Chat</h4> <h4 class="c-chat__title">Chat</h4>
<div class="c-chat__list"> <div class="c-chat__list">
<div v-if="nextPageUrl" class="c-chat__load-more">
<button @click="loadMoreMessages" class="c-chat__load-more-btn">
Load More
</button>
</div>
<div <div
v-for="message in chat.messages" v-for="message in messages"
:key="message.id" :key="message.id"
class="c-chat__message" :class="[
'c-chat__message',
{
'c-chat__message--system': message.user === null,
'c-chat__message--own': isOwnMessage(message.user?.id),
'c-chat__message--other': message.user !== null && !isOwnMessage(message.user?.id)
},
]"
> >
<!-- Standard User Chat Message -->
<template v-if="message.user">
<div class="c-chat__message-header"> <div class="c-chat__message-header">
<span class="c-chat__message-author">{{ <span class="c-chat__message-author">{{
message.user.name message.user.name
}}</span> }}</span>
<span class="c-chat__message-time">{{ <span
new Date(message.created_at).toLocaleString() class="c-chat__message-time"
}}</span> :title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div> </div>
<p class="c-chat__message-text"> <p class="c-chat__message-text" v-html="parseMessageContent(message)"></p>
{{ message.content }}
</p>
<!-- Attached Media Display --> <!-- Attached Media Display -->
<div <div
@@ -144,8 +358,26 @@ function closeLightbox() {
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Subtle Activity Log System Message -->
<template v-else>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span
class="c-chat__system-text"
v-html="parseMessageContent(message)"
></span>
<span
class="c-chat__system-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div> </div>
<div v-if="chat.messages.length === 0" class="c-chat__empty"> </template>
</div>
<div v-if="messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
</div> </div>
</div> </div>
@@ -157,7 +389,8 @@ function closeLightbox() {
id="content" id="content"
rows="3" rows="3"
class="c-chat__textarea" class="c-chat__textarea"
placeholder="Type a message..." placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
@keydown.enter.exact.prevent="submit"
></textarea> ></textarea>
<div v-if="form.errors.content" class="c-chat__error"> <div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }} {{ form.errors.content }}
@@ -215,165 +448,22 @@ function closeLightbox() {
</form> </form>
<!-- Gorgeous Dark Lightbox Modal --> <!-- Gorgeous Dark Lightbox Modal -->
<div <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
v-if="activeLightboxUrl" <button @click="closeLightbox" class="c-lightbox__close"></button>
class="c-chat__lightbox" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button @click="closeLightbox" class="c-chat__lightbox-close">
</button>
<div class="c-chat__lightbox-content" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-chat__lightbox-image" class="c-lightbox__image"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-chat__lightbox-video" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply mt-8;
}
.c-chat__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-chat__list {
@apply mt-4 space-y-4;
}
.c-chat__message {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-chat__message-header {
@apply flex justify-between;
}
.c-chat__message-author {
@apply font-semibold;
}
.c-chat__message-time {
@apply text-xs text-gray-500;
}
.c-chat__message-text {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-chat__empty {
@apply text-gray-500;
}
.c-chat__form {
@apply mt-6 space-y-6;
}
.c-chat__form-group {
@apply space-y-2;
}
.c-chat__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-chat__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-chat__error {
@apply text-sm text-red-600;
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
}
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs text-neutral-500 transition-colors hover:text-foreground;
}
.c-chat__attach-icon {
@apply size-3.5;
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate;
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-chat__submit-box {
@apply flex items-center gap-4;
}
.c-chat__button {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
/* Lightbox Styling */
.c-chat__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-chat__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-chat__lightbox-content {
@apply max-h-full max-w-full;
}
.c-chat__lightbox-image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-chat__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
}>();
const processedContent = computed(() => {
return props.message.content.replace(/<user:(\d+)>/g, (match, userId) => {
// This is a placeholder for a more robust user lookup
return `<a href="${route('users.show', userId)}" class="text-blue-500 hover:underline">@user${userId}</a>`;
});
});
</script>
<template>
<div v-html="processedContent"></div>
</template>

Some files were not shown because too many files have changed in this diff Show More