Compare commits

..

2 Commits

123 changed files with 1116 additions and 6042 deletions

View File

@ -63,7 +63,3 @@ 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}"

6
.gitignore vendored
View File

@ -27,9 +27,3 @@ yarn-error.log
/.nova /.nova
/.vscode /.vscode
/.zed /.zed
/public/sw.js
/public/workbox-*.js
/tests/Browser/console
/tests/Browser/screenshots
/tests/Browser/source
/storage/debugbar/

View File

@ -5,6 +5,11 @@
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.
@ -19,7 +24,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- tightenco/ziggy (ZIGGY) - v2 - tightenco/ziggy (ZIGGY) - v2
- larastan/larastan (LARASTAN) - v3 - larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2 - laravel/boost (BOOST) - v2
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1 - laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
@ -54,6 +58,12 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Stick to existing directory structure; don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Activity Service
- The `app/Services/ActivityService.php` class is used to create system messages and activities.
- To create a system message, use the `createMessage` method. The `$user` parameter should be `null` to indicate a system message.
- The `createMutation` method can be used to create a mutation and its associated system message.
## Frontend Bundling ## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
@ -127,6 +137,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===

View File

@ -59,27 +59,6 @@ We created `app/Services/ActivityService.php` to centralize the creation of syst
* **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. * **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. * **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.

View File

@ -14,28 +14,4 @@ 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 & 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: * **Testing:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running `vendor/bin/pest`.
```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) }
]
})
});
```
---
## 3. Advanced Browser Testing & Architectural Guidelines (Laravel Dusk)
* **Dusk Database Locks on SQLite:** Never use the `DatabaseTransactions` trait in Laravel Dusk tests if the project is backed by a SQLite database file. Since Dusk runs in concurrent, separate processes (the CLI runner and the web server), an active uncommitted database transaction in the CLI will hold a SQLite write lock. This causes the web server to fail with `SQLSTATE[HY000]: General error: 5 database is locked`. Instead, dynamically create and clean up models during test execution or use sequential DB seeds.
* **Inertia/Vue SPA Testing:** Browser tests targeting asynchronously rendered Single Page Applications must **always** use explicit `waitForText()` or `waitFor()` selectors before executing assertions. Direct assertions like `assertSee()` are immediate and will fail if the JavaScript has not finished compiling and mounting the DOM.
* **Synchronous Queue for WebSockets:** Running real-time WebSocket / Echo Dusk tests locally requires setting `QUEUE_CONNECTION=sync` and `BROADCAST_CONNECTION=reverb` in your `.env` file. This ensures broadcast jobs execute immediately on the web server rather than buffering in the database (which fails without a running background queue worker).
* **Automatic ID-to-UUID Serialization:** All models carrying a `uuid` column (such as `User`, `Dynamic`, `Ledger`, `Mutation`, and `PredefinedMutation`) must use the `App\Concerns\SerializesIdToUuid` trait. This guarantees that any raw model or relationship array/JSON conversion automatically replaces the integer `id` with the secure `uuid` under the `'id'` key.
* **UUID Controller Inputs:** When the frontend submits relationship parameters (such as `predefined_mutation_id` in requests), it only knows and passes the secure UUID. The receiving backend controller must explicitly resolve this UUID to its internal database integer ID before inserting the record (e.g. `PredefinedMutation::where('uuid', $uuid)->value('id')`), maintaining column type safety and foreign key constraints without exposing integer IDs to the client.

24
IDEA.md
View File

@ -41,27 +41,3 @@ During this session, we successfully built out and verified several core archite
* Configured real-time notifications utilizing Laravel Reverb. * Configured real-time notifications utilizing Laravel Reverb.
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors. * 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**. * 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:userUuid>` 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**.

View File

@ -1,23 +0,0 @@
<?php
namespace App\Concerns;
trait SerializesIdToUuid
{
/**
* Convert the model's attributes to an array.
* Overrides the default model toArray method to replace 'id' with 'uuid'.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
$array = parent::toArray();
if (isset($this->uuid)) {
$array['id'] = $this->uuid;
}
return $array;
}
}

View File

@ -30,7 +30,6 @@ 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),
]; ];

View File

@ -30,7 +30,6 @@ 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),
]; ];

View File

@ -11,10 +11,10 @@ class DashboardController extends Controller
public function index(Request $request, ActivityService $activityService) public function index(Request $request, ActivityService $activityService)
{ {
$user = $request->user(); $user = $request->user();
$unreadDynamics = $activityService->getUnreadEntitiesGrouped($user); $unreadEntities = $activityService->getUnreadEntitiesGrouped($user);
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [
'unreadDynamics' => $unreadDynamics, 'unreadEntities' => $unreadEntities,
]); ]);
} }
} }

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreDynamicRequest;
use App\Http\Requests\UpdateDynamicRequest; use App\Http\Requests\UpdateDynamicRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Services\ActivityService; use App\Services\ActivityService;
@ -12,7 +13,6 @@ 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.
*/ */
@ -52,26 +52,24 @@ class DynamicController extends Controller
$activityService->updateCursor($request->user(), $dynamic); $activityService->updateCursor($request->user(), $dynamic);
$dynamic->load(['ledgers.media', 'participants', 'chat']); $dynamic->load([
'ledgers.media',
'participants',
'chat.messages.user',
'chat.messages.media'
]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'ledgers' => $dynamic->ledgers, 'isOwner' => $isOwner,
'participants' => $dynamic->participants,
'messages' => $dynamic->getOrCreateChat()->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 $dynamic->getOrCreateChat()->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.
*/ */

View File

@ -5,18 +5,16 @@ namespace App\Http\Controllers;
use App\Mail\DynamicInvitationMail; use App\Mail\DynamicInvitationMail;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\DynamicInvitation; use App\Models\DynamicInvitation;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
class DynamicInvitationController extends Controller class DynamicInvitationController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
/** /**
* Show the form for creating a new invitation. * Show the form for creating a new invitation.
*/ */
@ -41,7 +39,7 @@ class DynamicInvitationController extends Controller
->where('role', 'owner') ->where('role', 'owner')
->exists(); ->exists();
if (! $isOwner) { if (!$isOwner) {
abort(403, 'Only dynamic owners can invite other users.'); abort(403, 'Only dynamic owners can invite other users.');
} }
@ -94,7 +92,7 @@ class DynamicInvitationController extends Controller
public function accept(Request $request, string $token) public function accept(Request $request, string $token)
{ {
// Must be signed! // Must be signed!
if (! $request->hasValidSignature()) { if (!$request->hasValidSignature()) {
abort(401, 'Invalid or expired signature.'); abort(401, 'Invalid or expired signature.');
} }
@ -118,15 +116,15 @@ class DynamicInvitationController extends Controller
// Log to Dynamic chat activity log! // Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([ $dynamic->chat->messages()->create([
'user_id' => null, 'user_id' => null,
'content' => "<user:{$request->user()->uuid}> joined the Dynamic as a ".strtoupper($invitation->role), 'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role),
'subject_id' => $request->user()->id, 'subject_id' => $request->user()->id,
'subject_type' => User::class, 'subject_type' => \App\Models\User::class,
]); ]);
// Delete the invitation record // Delete the invitation record
$invitation->delete(); $invitation->delete();
}); });
return redirect()->route('dynamics.show', $invitation->dynamic)->with('success', 'Successfully joined the dynamic!'); return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!');
} }
} }

View File

@ -6,8 +6,8 @@ use App\Http\Requests\StoreLedgerRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Services\ActivityService; use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia; use Inertia\Inertia;
class LedgerController extends Controller class LedgerController extends Controller
@ -39,7 +39,6 @@ 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')) {
@ -69,65 +68,41 @@ class LedgerController extends Controller
$ledger->load([ $ledger->load([
'media', 'media',
'predefinedMutations',
'mutations' => function ($query) { 'mutations' => function ($query) {
$query->latest(); $query->latest();
}, },
'mutations.user', 'mutations.user',
'mutations.media', 'mutations.media',
'mutations.chat', 'mutations.chat.messages.user',
'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' => $dynamic,
'ledger' => $ledger, 'ledger' => $ledger,
'messages' => $dynamic->getOrCreateChat()->messages()->with(['user', 'media'])->latest()->paginate(\App\Models\Message::PAGINATION_COUNT), 'isOwner' => $isOwner,
'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 $dynamic->getOrCreateChat()->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, Ledger $ledger) public function edit(Ledger $ledger)
{ {
$this->authorize('update', $ledger); //
return Inertia::render('Ledgers/Edit', [
'dynamic' => $dynamic,
'ledger' => $ledger,
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(StoreLedgerRequest $request, Dynamic $dynamic, Ledger $ledger) public function update(Request $request, 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]);
} }
/** /**

View File

@ -2,38 +2,47 @@
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\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Notifications\NewActivityNotification;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
class MutationController extends Controller class MutationController extends Controller
{ {
use AuthorizesRequests; /**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('create', [Mutation::class, $ledger]); $isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
$status = $request->user()->can('update', $ledger) ? 'approved' : 'pending'; // If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) { $mutation = DB::transaction(function () use ($request, $ledger, $status) {
$predefinedId = null;
if ($request->filled('predefined_mutation_id')) {
$predefinedId = \App\Models\PredefinedMutation::where('uuid', $request->input('predefined_mutation_id'))->value('id');
}
$mutation = $ledger->mutations()->create([ $mutation = $ledger->mutations()->create([
...$request->except(['media', 'type', 'status', 'predefined_mutation_id']), ...$request->except(['media', 'type', 'status']),
'predefined_mutation_id' => $predefinedId,
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'type' => $request->input('type', $request->input('amount') >= 0 ? 'addition' : 'subtraction'), 'type' => $request->input('type', $request->input('amount') >= 0 ? 'addition' : 'subtraction'),
'status' => $status, 'status' => $status,
@ -50,6 +59,7 @@ class MutationController extends Controller
} }
} }
// Only increment score if the status is approved!
if ($status === 'approved') { if ($status === 'approved') {
$ledger->increment('score', $request->validated('amount')); $ledger->increment('score', $request->validated('amount'));
} }
@ -57,46 +67,70 @@ class MutationController extends Controller
return $mutation; return $mutation;
}); });
$recipients = $dynamic->participants()->where('users.id', '!=', $request->user()->id)->get(); // Log to Mutation and Dynamic chats
$message = $status === 'approved' $user = $request->user();
? "{$request->user()->name} added a new entry: \"{$mutation->description}\"."
: "{$request->user()->name} suggested a new entry: \"{$mutation->description}\".";
if ($recipients->isNotEmpty()) { $mutationMsg = $mutation->chat->messages()->create([
Notification::send($recipients, new NewActivityNotification([ 'user_id' => $user->id,
'content' => $message, 'content' => $status === 'approved'
'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]), ? "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(new \App\Events\MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
public function show(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) /**
* Display the specified resource.
*/
public function show(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
$this->authorize('view', $mutation); //
$mutation->load('user', 'ledger', 'media', 'chat.messages.user');
$dynamic = $mutation->ledger->dynamic;
$dynamic->load('participants');
$user = $request->user();
return inertia('Mutations/Show', [
'mutation' => $mutation,
'dynamic' => $dynamic,
'can' => [
'update' => $user->can('update', $dynamic),
],
]);
} }
/**
* Show the form for editing the specified resource.
*/
public function edit(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
$this->authorize('update', $mutation); // 1. Authorize - only owners can update mutation status!
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
$request->validate(['status' => ['required', 'string', 'in:approved,rejected']]); if (!$isOwner) {
abort(403, 'Only dynamic owners can approve or reject mutations.');
}
$request->validate([
'status' => ['required', 'string', 'in:approved,rejected'],
]);
$oldStatus = $mutation->status; $oldStatus = $mutation->status;
$newStatus = $request->input('status'); $newStatus = $request->input('status');
@ -104,6 +138,7 @@ class MutationController extends Controller
DB::transaction(function () use ($mutation, $ledger, $oldStatus, $newStatus) { DB::transaction(function () use ($mutation, $ledger, $oldStatus, $newStatus) {
$mutation->update(['status' => $newStatus]); $mutation->update(['status' => $newStatus]);
// Adjust the ledger score if status transitions to approved or from approved!
if ($oldStatus !== 'approved' && $newStatus === 'approved') { if ($oldStatus !== 'approved' && $newStatus === 'approved') {
$ledger->increment('score', $mutation->amount); $ledger->increment('score', $mutation->amount);
} elseif ($oldStatus === 'approved' && $newStatus !== 'approved') { } elseif ($oldStatus === 'approved' && $newStatus !== 'approved') {
@ -111,40 +146,40 @@ class MutationController extends Controller
} }
}); });
// Log to Mutation and Dynamic chats
$user = $request->user(); $user = $request->user();
$statusText = strtoupper($newStatus); $statusText = strtoupper($newStatus);
// Notify the suggester $mutationMsg = $mutation->chat->messages()->create([
$suggester = $mutation->user; 'user_id' => $user->id,
if ($suggester && $suggester->id !== $user->id) { 'content' => "System: Suggestion was {$statusText} by {$user->name}.",
Notification::send($suggester, new NewActivityNotification([ ]);
'content' => "Your suggestion \"{$mutation->description}\" was {$statusText} by {$user->name}.", broadcast(new \App\Events\MessageSent($mutationMsg));
'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]),
])); if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} APPROVED the suggestion \"{$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} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
]);
} }
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time update event!
broadcast(new \App\Events\MutationUpdated($mutation));
return redirect()->back(); return redirect()->back();
} }
public function void(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) /**
* Remove the specified resource from storage.
*/
public function destroy(Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
$this->authorize('void', $mutation); //
DB::transaction(function() use ($mutation, $ledger) {
if ($mutation->status === 'approved') {
$ledger->decrement('score', $mutation->amount);
}
$mutation->update(['status' => 'voided']);
});
$recipients = $dynamic->participants()->where('users.id', '!=', $request->user()->id)->get();
if ($recipients->isNotEmpty()) {
Notification::send($recipients, new NewActivityNotification([
'content' => "{$request->user()->name} voided an entry: \"{$mutation->description}\".",
'url' => route('dynamics.ledgers.show', [$dynamic, $ledger]),
]));
}
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
} }

View File

@ -1,57 +0,0 @@
<?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->uuid,
'name' => $user->name,
'display_name' => $participant->pivot->display_name,
'role' => $participant->pivot->role,
],
'mutations' => $mutations,
]);
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\PredefinedMutation; use App\Models\PredefinedMutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -16,76 +15,32 @@ class PredefinedMutationController extends Controller
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
public function index(Dynamic $dynamic, Ledger $ledger) public function index(Dynamic $dynamic)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/PredefinedMutations/Index', [ return Inertia::render('Dynamics/PredefinedMutations/Index', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'ledger' => $ledger, 'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(),
'predefined_mutations' => $ledger->predefinedMutations()->latest()->get(),
]); ]);
} }
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
*/ */
public function store(Request $request, Dynamic $dynamic, Ledger $ledger) public function store(Request $request, Dynamic $dynamic)
{ {
$this->authorize('update', $dynamic); $this->authorize('update', $dynamic);
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'], 'description' => ['nullable', 'string'],
'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'], 'amount' => ['required', 'integer'],
'type' => ['required', 'string', 'in:reward,penalty'],
]); ]);
$ledger->predefinedMutations()->create($request->all()); $dynamic->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.ledgers.predefined-mutations.index', [$dynamic, $ledger]); return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
}
/**
* 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]);
} }
} }

View File

@ -19,14 +19,9 @@ 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,
]); ]);
} }

View File

@ -1,37 +0,0 @@
<?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]);
}
}

View File

@ -2,7 +2,6 @@
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;
@ -48,8 +47,7 @@ class HandleInertiaRequests extends Middleware
return 0; return 0;
} }
$service = app(ActivityService::class); $service = app(\App\Services\ActivityService::class);
return count($service->getUnreadEntitiesGrouped($request->user())); return count($service->getUnreadEntitiesGrouped($request->user()));
}, },
]; ];

View File

@ -25,11 +25,10 @@ class StoreMutationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'amount' => ['required', 'integer', 'not_in:0', 'min:-1000', 'max:1000'], 'amount' => ['required', 'integer'],
'description' => ['required', 'string'], 'description' => ['required', 'string'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'predefined_mutation_id' => ['nullable', 'exists:predefined_mutations,uuid'],
'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'],
]; ];

View File

@ -5,6 +5,8 @@ namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use App\Models\Dynamic;
class UpdateDynamicRequest extends FormRequest class UpdateDynamicRequest extends FormRequest
{ {
/** /**
@ -20,7 +22,7 @@ class UpdateDynamicRequest extends FormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, ValidationRule|array|string> * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/ */
public function rules(): array public function rules(): array
{ {

View File

@ -10,24 +10,20 @@ use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
class DynamicInvitationMail extends Mailable class DynamicInvitationMail extends Mailable {
{
use Queueable, SerializesModels; use Queueable, SerializesModels;
public function __construct(public DynamicInvitation $invitation, public string $inviterName) public function __construct(public DynamicInvitation $invitation, public string $inviterName) {
{
// //
} }
public function envelope(): Envelope public function envelope(): Envelope {
{
return new Envelope( return new Envelope(
subject: 'Invitation to Join Dynamic: '.$this->invitation->dynamic->name, subject: 'Invitation to Join Dynamic: ' . $this->invitation->dynamic->name,
); );
} }
public function content(): Content public function content(): Content {
{
$acceptUrl = URL::temporarySignedRoute( $acceptUrl = URL::temporarySignedRoute(
'dynamics.invitations.accept', 'dynamics.invitations.accept',
$this->invitation->expires_at, $this->invitation->expires_at,

View File

@ -22,8 +22,4 @@ class Chat extends Model
{ {
return $this->hasMany(Message::class); return $this->hasMany(Message::class);
} }
public function getSubjectUrlAttribute(): string {
return $this->chatable?->url ?? '';
}
} }

View File

@ -8,14 +8,11 @@ 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;
use App\Models\Chat;
use App\Concerns\SerializesIdToUuid;
class Dynamic extends Model class Dynamic extends Model
{ {
/** @use HasFactory<DynamicFactory> */ /** @use HasFactory<DynamicFactory> */
use HasFactory, SerializesIdToUuid; use HasFactory;
protected $fillable = [ protected $fillable = [
'name', 'name',
@ -24,7 +21,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'participants')->withPivot('role', 'display_name'); return $this->belongsToMany(User::class, 'participants')->withPivot('role');
} }
public function ledgers(): HasMany public function ledgers(): HasMany
@ -37,11 +34,6 @@ class Dynamic extends Model
return $this->hasMany(DynamicInvitation::class); 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');
@ -49,30 +41,8 @@ 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);
}
public function getOrCreateChat(): Chat
{
if ($this->chat) {
return $this->chat;
}
return $this->chat()->create([]);
}
} }

View File

@ -6,8 +6,7 @@ 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;
class DynamicInvitation extends Model class DynamicInvitation extends Model {
{
use HasFactory; use HasFactory;
protected $fillable = [ protected $fillable = [
@ -22,13 +21,11 @@ class DynamicInvitation extends Model
'expires_at' => 'datetime', 'expires_at' => 'datetime',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo {
{
return $this->belongsTo(Dynamic::class); return $this->belongsTo(Dynamic::class);
} }
public function isExpired(): bool public function isExpired(): bool {
{
return $this->expires_at->isPast(); return $this->expires_at->isPast();
} }
} }

View File

@ -7,14 +7,11 @@ 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;
use App\Concerns\SerializesIdToUuid;
class Ledger extends Model class Ledger extends Model
{ {
/** @use HasFactory<LedgerFactory> */ /** @use HasFactory<LedgerFactory> */
use HasFactory, SerializesIdToUuid; use HasFactory;
protected $fillable = [ protected $fillable = [
'dynamic_id', 'dynamic_id',
@ -22,7 +19,6 @@ class Ledger extends Model
'rules', 'rules',
'score', 'score',
'alignment', 'alignment',
'status',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo
@ -35,29 +31,8 @@ class Ledger extends Model
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function predefinedMutations(): HasMany public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
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);
}
} }

View File

@ -6,8 +6,7 @@ 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 = [
@ -18,13 +17,11 @@ 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);
} }
} }

View File

@ -6,7 +6,7 @@ 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; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model class Message extends Model
@ -14,8 +14,6 @@ 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',
@ -39,12 +37,8 @@ class Message extends Model
return $this->morphTo(); return $this->morphTo();
} }
public function media(): MorphMany public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
} }
public function getSubjectUrlAttribute(): string {
return $this->subject->url ?? '';
}
} }

View File

@ -2,20 +2,16 @@
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;
use App\Concerns\SerializesIdToUuid;
class Mutation extends Model class Mutation extends Model
{ {
/** @use HasFactory<MutationFactory> */ /** @use HasFactory<MutationFactory> */
use HasFactory, SerializesIdToUuid; use HasFactory;
protected $fillable = [ protected $fillable = [
'ledger_id', 'ledger_id',
@ -47,97 +43,15 @@ class Mutation extends Model
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
} }
public function media(): MorphMany public function media(): \Illuminate\Database\Eloquent\Relations\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->uuid}>."
: "Suggestion was created by <user:{$user->uuid}>.",
'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->uuid}> 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->uuid}> 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));
// Trigger the real-time creation broadcast dynamically
broadcast(new \App\Events\MutationCreated($mutation));
}); });
static::updated(function (Mutation $mutation) {
if ($mutation->wasChanged('status')) {
$status = $mutation->getAttribute('status');
$user = request()->user();
$ledger = $mutation->ledger;
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => null,
'content' => $status === 'approved'
? "<user:{$user->uuid}> APPROVED the suggestion \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger."
: "<user:{$user->uuid}> DENIED the suggestion \"{$mutation->description}\" for ".($mutation->amount >= 0 ? '+' : '')."{$mutation->amount} points on \"{$ledger->name}\" ledger.",
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
$dynamic = $ledger->dynamic;
$dynamic->chat->messages()->create([
'user_id' => null,
'content' => $mutationMsg->content,
'subject_id' => $mutation->id,
'subject_type' => Mutation::class,
]);
broadcast(new \App\Events\MutationUpdated($mutation));
}
});
}
protected $appends = ['can'];
public function getCanAttribute(): array
{
return [
'update' => auth()->user()?->can('update', $this) ?? false,
'void' => auth()->user()?->can('void', $this) ?? false,
];
}
public function getRouteKeyName()
{
return 'uuid';
} }
} }

View File

@ -12,6 +12,5 @@ class Participant extends Pivot
'user_id', 'user_id',
'dynamic_id', 'dynamic_id',
'role', 'role',
'display_name',
]; ];
} }

View File

@ -5,34 +5,21 @@ namespace App\Models;
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\Support\Str;
use App\Concerns\SerializesIdToUuid;
class PredefinedMutation extends Model class PredefinedMutation extends Model
{ {
use HasFactory, SerializesIdToUuid; use HasFactory;
protected $fillable = [ protected $fillable = [
'ledger_id', 'dynamic_id',
'name', 'name',
'description', 'description',
'amount', 'amount',
'type',
]; ];
public function ledger(): BelongsTo public function dynamic(): BelongsTo
{ {
return $this->belongsTo(Ledger::class); return $this->belongsTo(Dynamic::class);
}
protected static function booted(): void
{
static::creating(function ($model) {
$model->uuid = (string) Str::uuid();
});
}
public function getRouteKeyName()
{
return 'uuid';
} }
} }

View File

@ -10,12 +10,9 @@ 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;
use App\Concerns\SerializesIdToUuid;
/** /**
* @property int $id * @property int $id
@ -35,7 +32,7 @@ use App\Concerns\SerializesIdToUuid;
class User extends Authenticatable implements PasskeyUser class User extends Authenticatable implements PasskeyUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasFactory, HasPushSubscriptions, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable, SerializesIdToUuid; use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
public function dynamics() public function dynamics()
{ {
@ -52,13 +49,6 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(ReadCursor::class); 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.
* *
@ -72,16 +62,4 @@ 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';
}
} }

View File

@ -1,43 +0,0 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use NotificationChannels\WebPush\WebPushChannel;
use NotificationChannels\WebPush\WebPushMessage;
class NewActivityNotification extends Notification
{
use Queueable;
public array $activity;
public function __construct(array $activity)
{
$this->activity = $activity;
}
public function via(object $notifiable): array
{
return [WebPushChannel::class, 'database'];
}
public function toWebPush(object $notifiable): WebPushMessage
{
return (new WebPushMessage)
->title('New Activity')
->icon('/apple-touch-icon.png')
->body($this->activity['content'])
->action('View', 'view')
->data(['url' => $this->activity['url']]);
}
public function toArray(object $notifiable): array
{
return [
'content' => $this->activity['content'],
'url' => $this->activity['url'],
];
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace App\Policies;
use App\Models\Chat;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ChatPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Chat $chat): bool
{
$chatable = $chat->chatable;
return $user->can('view', $chatable);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Chat $chat): bool
{
return $this->view($user, $chat);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Chat $chat): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Chat $chat): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Chat $chat): bool
{
return false;
}
}

View File

@ -37,15 +37,7 @@ class LedgerPolicy
*/ */
public function update(User $user, Ledger $ledger): bool public function update(User $user, Ledger $ledger): bool
{ {
return $user->can('update', $ledger->dynamic); return false;
}
/**
* Determine whether the user can close the model.
*/
public function close(User $user, Ledger $ledger): bool
{
return $user->can('update', $ledger->dynamic);
} }
/** /**

View File

@ -2,7 +2,6 @@
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;
@ -13,38 +12,8 @@ class MutationPolicy
*/ */
public function view(User $user, Mutation $mutation): bool public function view(User $user, Mutation $mutation): bool
{ {
return $user->can('view', $mutation->ledger); $dynamic = $mutation->ledger->dynamic;
}
/**
* Determine whether the user can create mutations.
*/
public function create(User $user, Ledger $ledger): bool
{
$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';
}
} }

View File

@ -3,8 +3,6 @@
namespace App\Providers; namespace App\Providers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
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;
@ -25,15 +23,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
JsonResource::withoutWrapping();
$this->configureDefaults(); $this->configureDefaults();
Relation::morphMap([
'user' => \App\Models\User::class,
'dynamic' => \App\Models\Dynamic::class,
'ledger' => \App\Models\Ledger::class,
'mutation' => \App\Models\Mutation::class,
]);
} }
/** /**

View File

@ -2,14 +2,14 @@
namespace App\Services; namespace App\Services;
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\ReadCursor; use App\Models\ReadCursor;
use App\Models\User;
use App\Notifications\NewActivityNotification;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ActivityService class ActivityService
{ {
@ -33,7 +33,7 @@ class ActivityService
/** /**
* Get the read cursor timestamp for a user on a specific entity. * Get the read cursor timestamp for a user on a specific entity.
*/ */
public function getCursorReadAt(User $user, $entity): CarbonInterface public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface
{ {
$cursor = ReadCursor::where([ $cursor = ReadCursor::where([
'user_id' => $user->id, 'user_id' => $user->id,
@ -45,9 +45,8 @@ class ActivityService
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01'); return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
} }
public function createMessage($dynamic, $user, $content, $subject = null) public function createMessage($chat, $user, $content, $subject = null)
{ {
$chat = $dynamic->getOrCreateChat();
$message = $chat->messages()->create([ $message = $chat->messages()->create([
'user_id' => $user ? $user->id : null, 'user_id' => $user ? $user->id : null,
'content' => $content, 'content' => $content,
@ -55,8 +54,6 @@ class ActivityService
'subject_type' => $subject ? get_class($subject) : null, 'subject_type' => $subject ? get_class($subject) : null,
]); ]);
$this->notify($message);
return $message; return $message;
} }
@ -73,54 +70,27 @@ class ActivityService
return $mutation; 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. * Retrieve all activities for a given entity.
*/ */
public function getActivitiesForDynamic(Dynamic $dynamic): array public function getActivitiesForEntity($entity): array
{ {
$chat = $dynamic->getOrCreateChat(); if ($entity instanceof Dynamic) {
if (!$chat) { $chatId = $entity->chat->id;
} elseif ($entity instanceof Ledger) {
$chatId = $entity->dynamic->chat->id;
} else {
return []; return [];
} }
$participants = $dynamic->participants()->withPivot('display_name')->get(); $messages = Message::where('chat_id', $chatId)
$participantsMap = $participants->reduce(function ($acc, $p) {
$acc[$p->uuid] = $p->pivot->display_name ?? $p->name;
return $acc;
}, []);
$messages = Message::where('chat_id', $chat->id)
->with(['user', 'subject']) ->with(['user', 'subject'])
->latest() ->latest()
->get(); ->get();
return $messages->map(function ($message) use ($participantsMap) { return $messages->map(function ($message) {
$messageData = $message->toArray(); $messageData = $message->toArray();
$messageData['url'] = $this->getUrlForMessage($message); $messageData['url'] = $this->getUrlForMessage($message);
// Resolve <user:id> placeholders to actual names/display names
$messageData['content'] = preg_replace_callback('/<user:([0-9a-f-]+)>/', function ($matches) use ($participantsMap) {
$userId = $matches[1];
return $participantsMap[$userId] ?? "User #{$userId}";
}, $message->content);
return $messageData; return $messageData;
})->all(); })->all();
} }
@ -130,23 +100,25 @@ class ActivityService
*/ */
public function getUnreadEntitiesGrouped(User $user): array public function getUnreadEntitiesGrouped(User $user): array
{ {
$groupedDynamics = []; $groupedEntities = [];
$participatingDynamics = $user->dynamics()->with(['chat', 'ledgers'])->get(); $participatingDynamics = $user->dynamics()->with('ledgers')->get();
foreach ($participatingDynamics as $dynamic) { $entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
$readAt = $this->getCursorReadAt($user, $dynamic);
$activities = $this->getActivitiesForDynamic($dynamic);
$this->partitionAndGroupActivities($activities, $readAt, $dynamic, $groupedDynamics); foreach ($entities as $entity) {
$readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
} }
return $groupedDynamics; return $groupedEntities;
} }
/** /**
* Partition activities into read and unread, and construct the grouped entity metadata. * Partition activities into read and unread, and construct the grouped entity metadata.
*/ */
private function partitionAndGroupActivities(array $activities, CarbonInterface $readAt, Dynamic $dynamic, array &$groupedDynamics): void private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void
{ {
$alreadyRead = []; $alreadyRead = [];
$unread = []; $unread = [];
@ -159,16 +131,17 @@ class ActivityService
} }
} }
if (! empty($unread)) { if (!empty($unread)) {
$context = array_slice($alreadyRead, 0, 2); $context = array_slice($alreadyRead, 0, 2);
$groupedDynamics[] = [ $groupedEntities[] = [
'id' => $dynamic->id, 'id' => $entity->id,
'name' => $dynamic->name, 'name' => $entity->name,
'url' => route('dynamics.show', $dynamic->uuid), 'type' => Str::afterLast($type, '\\'),
'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => $context, 'context_activities' => $context,
'new_activities' => array_reverse($unread), 'new_activities' => $unread,
]; ];
} }
} }
@ -176,16 +149,11 @@ class ActivityService
private function getUrlForEntity($entity): string private function getUrlForEntity($entity): string
{ {
if ($entity instanceof Dynamic) { if ($entity instanceof Dynamic) {
return route('dynamics.show', $entity->uuid); return route('dynamics.show', $entity->id);
} }
if ($entity instanceof Ledger) { if ($entity instanceof Ledger) {
return route('dynamics.ledgers.show', [$entity->dynamic->uuid, $entity->uuid]); return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]);
}
if ($entity instanceof Message) {
$subject = $entity->subject;
return $this->getUrlForEntity($subject);
} }
return ''; return '';

View File

@ -10,9 +10,9 @@ use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__ . '/../routes/web.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__ . '/../routes/console.php',
channels: __DIR__.'/../routes/channels.php', channels: __DIR__ . '/../routes/channels.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
@ -31,6 +31,6 @@ return Application::configure(basePath: dirname(__DIR__))
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'), fn(Request $request) => $request->is('api/*'),
); );
})->create(); })->create();

View File

@ -3,13 +3,11 @@
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,
BroadcastServiceProvider::class, \Illuminate\Broadcasting\BroadcastServiceProvider::class,
ZiggyServiceProvider::class, \Tighten\Ziggy\ZiggyServiceProvider::class,
]; ];

View File

@ -11,7 +11,6 @@
"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",
@ -22,10 +21,8 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.24", "fakerphp/faker": "^1.24",
"fruitcake/laravel-debugbar": "^4.4",
"larastan/larastan": "^3.9", "larastan/larastan": "^3.9",
"laravel/boost": "^2.2", "laravel/boost": "^2.2",
"laravel/dusk": "^8.6",
"laravel/pail": "^1.2.5", "laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6", "laravel/pao": "^1.0.6",
"laravel/pint": "^1.27", "laravel/pint": "^1.27",

786
composer.lock generated
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": "349232eb2e405a447b06a60c3c01fe4c", "content-hash": "4f6fe33dc680e6446bd6318d5bdd9ec9",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -1461,72 +1461,6 @@
}, },
"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",
@ -2825,77 +2759,6 @@
], ],
"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",
@ -5164,71 +5027,6 @@
], ],
"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",
@ -6904,86 +6702,6 @@
], ],
"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",
@ -8757,95 +8475,6 @@
], ],
"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",
@ -9344,109 +8973,6 @@
], ],
"time": "2025-08-08T12:00:00+00:00" "time": "2025-08-08T12:00:00+00:00"
}, },
{
"name": "fruitcake/laravel-debugbar",
"version": "v4.4.0",
"source": {
"type": "git",
"url": "https://github.com/fruitcake/laravel-debugbar.git",
"reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/fruitcake/laravel-debugbar/zipball/80ef956bda9e1a5824037d6f2cd06e73092e5634",
"reference": "80ef956bda9e1a5824037d6f2cd06e73092e5634",
"shasum": ""
},
"require": {
"illuminate/routing": "^11|^12|^13.0",
"illuminate/session": "^11|^12|^13.0",
"illuminate/support": "^11|^12|^13.0",
"php": "^8.2",
"php-debugbar/php-debugbar": "^3.8.0",
"php-debugbar/symfony-bridge": "^1.1"
},
"replace": {
"barryvdh/laravel-debugbar": "self.version"
},
"require-dev": {
"larastan/larastan": "^3",
"laravel/ai": "^0.8",
"laravel/octane": "^2",
"laravel/pennant": "^1",
"laravel/pint": "^1",
"laravel/telescope": "^5.16",
"livewire/livewire": "^3.7|^4",
"mockery/mockery": "^1.3.3",
"orchestra/testbench-dusk": "^9|^10|^11",
"php-debugbar/twig-bridge": "^2.0",
"phpstan/phpstan-phpunit": "^2",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11",
"shipmonk/phpstan-rules": "^4.3"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Debugbar": "Fruitcake\\LaravelDebugbar\\Facades\\Debugbar"
},
"providers": [
"Fruitcake\\LaravelDebugbar\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "4.2-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Fruitcake\\LaravelDebugbar\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fruitcake",
"homepage": "https://fruitcake.nl"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "PHP Debugbar integration for Laravel",
"keywords": [
"barryvdh",
"debug",
"debugbar",
"dev",
"laravel",
"profiler",
"webprofiler"
],
"support": {
"issues": "https://github.com/fruitcake/laravel-debugbar/issues",
"source": "https://github.com/fruitcake/laravel-debugbar/tree/v4.4.0"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-07-04T08:30:57+00:00"
},
{ {
"name": "hamcrest/hamcrest-php", "name": "hamcrest/hamcrest-php",
"version": "v2.1.1", "version": "v2.1.1",
@ -9817,80 +9343,6 @@
}, },
"time": "2026-06-09T10:21:08+00:00" "time": "2026-06-09T10:21:08+00:00"
}, },
{
"name": "laravel/dusk",
"version": "v8.6.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/dusk.git",
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143",
"reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-zip": "*",
"guzzlehttp/guzzle": "^7.5",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
"php-webdriver/webdriver": "^1.15.2",
"symfony/console": "^6.2|^7.0|^8.0",
"symfony/finder": "^6.2|^7.0|^8.0",
"symfony/process": "^6.2|^7.0|^8.0",
"vlucas/phpdotenv": "^5.2"
},
"require-dev": {
"laravel/framework": "^10.0|^11.0|^12.0|^13.0",
"mockery/mockery": "^1.6",
"orchestra/testbench-core": "^8.19|^9.17|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.1|^11.0|^12.0.1",
"psy/psysh": "^0.11.12|^0.12",
"symfony/yaml": "^6.2|^7.0|^8.0"
},
"suggest": {
"ext-pcntl": "Used to gracefully terminate Dusk when tests are running."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Dusk\\DuskServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Dusk\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Dusk provides simple end-to-end testing and browser automation.",
"keywords": [
"laravel",
"testing",
"webdriver"
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
"source": "https://github.com/laravel/dusk/tree/v8.6.0"
},
"time": "2026-04-15T14:50:40+00:00"
},
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
"version": "v0.8.1", "version": "v0.8.1",
@ -11144,240 +10596,6 @@
}, },
"time": "2022-02-21T01:04:05+00:00" "time": "2022-02-21T01:04:05+00:00"
}, },
{
"name": "php-debugbar/php-debugbar",
"version": "v3.8.0",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/php-debugbar.git",
"reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-debugbar/php-debugbar/zipball/18ced90d4b882ed449b2278fea8692f8f7d1c13c",
"reference": "18ced90d4b882ed449b2278fea8692f8f7d1c13c",
"shasum": ""
},
"require": {
"php": "^8.2",
"psr/log": "^1|^2|^3",
"symfony/var-dumper": "^5.4|^6|^7|^8"
},
"replace": {
"maximebf/debugbar": "self.version"
},
"require-dev": {
"dbrekelmans/bdi": "^1.4",
"friendsofphp/php-cs-fixer": "^3.92",
"monolog/monolog": "^3.9",
"php-debugbar/doctrine-bridge": "^3@dev",
"php-debugbar/monolog-bridge": "^1@dev",
"php-debugbar/symfony-bridge": "^1@dev",
"php-debugbar/twig-bridge": "^2@dev",
"phpstan/phpstan": "^2.1",
"phpstan/phpstan-phpunit": "^2.0",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^10",
"predis/predis": "^3.3",
"shipmonk/phpstan-rules": "^4.3",
"symfony/browser-kit": "^6.4|7.0",
"symfony/dom-crawler": "^6.4|^7",
"symfony/event-dispatcher": "^5.4|^6.4|^7.3|^8.0",
"symfony/http-foundation": "^5.4|^6.4|^7.3|^8.0",
"symfony/mailer": "^5.4|^6.4|^7.3|^8.0",
"symfony/panther": "^1|^2.1",
"twig/twig": "^3.11.2"
},
"suggest": {
"php-debugbar/doctrine-bridge": "To integrate Doctrine with php-debugbar.",
"php-debugbar/monolog-bridge": "To integrate Monolog with php-debugbar.",
"php-debugbar/symfony-bridge": "To integrate Symfony with php-debugbar.",
"php-debugbar/twig-bridge": "To integrate Twig with php-debugbar."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.8-dev"
}
},
"autoload": {
"psr-4": {
"DebugBar\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Bouroumeau-Fuseau",
"email": "maxime.bouroumeau@gmail.com",
"homepage": "http://maximebf.com"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Debug bar in the browser for php application",
"homepage": "https://github.com/php-debugbar/php-debugbar",
"keywords": [
"debug",
"debug bar",
"debugbar",
"dev",
"profiler",
"toolbar"
],
"support": {
"issues": "https://github.com/php-debugbar/php-debugbar/issues",
"source": "https://github.com/php-debugbar/php-debugbar/tree/v3.8.0"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-07-02T12:38:20+00:00"
},
{
"name": "php-debugbar/symfony-bridge",
"version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/php-debugbar/symfony-bridge.git",
"reference": "e37d2debe5d316408b00d0ab2688d9c2cf59b5ad"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-debugbar/symfony-bridge/zipball/e37d2debe5d316408b00d0ab2688d9c2cf59b5ad",
"reference": "e37d2debe5d316408b00d0ab2688d9c2cf59b5ad",
"shasum": ""
},
"require": {
"php": "^8.2",
"php-debugbar/php-debugbar": "^3.1",
"symfony/http-foundation": "^5.4|^6.4|^7.3|^8.0"
},
"require-dev": {
"dbrekelmans/bdi": "^1.4",
"phpunit/phpunit": "^10",
"symfony/browser-kit": "^6|^7",
"symfony/dom-crawler": "^6|^7",
"symfony/mailer": "^5.4|^6.4|^7.3|^8.0",
"symfony/panther": "^1|^2.1"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev"
}
},
"autoload": {
"psr-4": {
"DebugBar\\Bridge\\Symfony\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maxime Bouroumeau-Fuseau",
"email": "maxime.bouroumeau@gmail.com",
"homepage": "http://maximebf.com"
},
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "Symfony bridge for PHP Debugbar",
"homepage": "https://github.com/php-debugbar/php-debugbar",
"keywords": [
"debugbar",
"dev",
"symfony"
],
"support": {
"issues": "https://github.com/php-debugbar/symfony-bridge/issues",
"source": "https://github.com/php-debugbar/symfony-bridge/tree/v1.1.0"
},
"time": "2026-01-15T14:47:34+00:00"
},
{
"name": "php-webdriver/webdriver",
"version": "1.16.0",
"source": {
"type": "git",
"url": "https://github.com/php-webdriver/php-webdriver.git",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-webdriver/php-webdriver/zipball/ac0662863aa120b4f645869f584013e4c4dba46a",
"reference": "ac0662863aa120b4f645869f584013e4c4dba46a",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-zip": "*",
"php": "^7.3 || ^8.0",
"symfony/polyfill-mbstring": "^1.12",
"symfony/process": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"replace": {
"facebook/webdriver": "*"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.20.0",
"ondram/ci-detector": "^4.0",
"php-coveralls/php-coveralls": "^2.4",
"php-mock/php-mock-phpunit": "^2.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpunit/phpunit": "^9.3",
"squizlabs/php_codesniffer": "^3.5",
"symfony/var-dumper": "^5.0 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"ext-simplexml": "For Firefox profile creation"
},
"type": "library",
"autoload": {
"files": [
"lib/Exception/TimeoutException.php"
],
"psr-4": {
"Facebook\\WebDriver\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP client for Selenium WebDriver. Previously facebook/webdriver.",
"homepage": "https://github.com/php-webdriver/php-webdriver",
"keywords": [
"Chromedriver",
"geckodriver",
"php",
"selenium",
"webdriver"
],
"support": {
"issues": "https://github.com/php-webdriver/php-webdriver/issues",
"source": "https://github.com/php-webdriver/php-webdriver/tree/1.16.0"
},
"time": "2025-12-28T23:57:40+00:00"
},
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.2", "version": "2.2.2",
@ -13033,5 +12251,5 @@
"php": "^8.4" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

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.",
]; ];

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Dynamic;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -26,7 +26,7 @@ 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,

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Chat;
use App\Models\Message; use App\Models\Message;
use App\Models\Chat;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View File

@ -2,8 +2,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\Ledger;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;

View File

@ -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'); // pending, approved, rejected, voided $table->string('status')->default('pending');
$table->timestamps(); $table->timestamps();
}); });
} }

View File

@ -4,10 +4,8 @@ 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
@ -18,8 +16,7 @@ return new class extends Migration
}); });
} }
public function down(): void public function down(): void {
{
Schema::dropIfExists('media'); Schema::dropIfExists('media');
} }
}; };

View File

@ -4,10 +4,8 @@ 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('dynamic_invitations', function (Blueprint $table) { Schema::create('dynamic_invitations', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete(); $table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
@ -19,8 +17,7 @@ return new class extends Migration
}); });
} }
public function down(): void public function down(): void {
{
Schema::dropIfExists('dynamic_invitations'); Schema::dropIfExists('dynamic_invitations');
} }
}; };

View File

@ -1,28 +0,0 @@
<?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');
});
}
};

View File

@ -1,28 +0,0 @@
<?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');
});
}
};

View File

@ -1,28 +0,0 @@
<?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();
});
}
};

View File

@ -1,40 +0,0 @@
<?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');
});
}
};

View File

@ -1,40 +0,0 @@
<?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');
});
}
};

View File

@ -1,40 +0,0 @@
<?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');
});
}
};

View File

@ -1,40 +0,0 @@
<?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');
});
}
};

View File

@ -1,40 +0,0 @@
<?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');
});
}
};

View File

@ -1,36 +0,0 @@
<?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'));
}
};

View File

@ -1,30 +0,0 @@
<?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) {
DB::table('messages')->where('subject_type', 'App\\Models\\User')->update(['subject_type' => 'user']);
DB::table('messages')->where('subject_type', 'App\\Models\\Mutation')->update(['subject_type' => 'mutation']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
DB::table('messages')->where('subject_type', 'user')->update(['subject_type' => 'App\\Models\\User']);
DB::table('messages')->where('subject_type', 'mutation')->update(['subject_type' => 'App\\Models\\Mutation']);
});
}
};

View File

@ -1,28 +0,0 @@
<?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('users', function (Blueprint $table) {
$table->dropUnique('users_uuid_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->unique('uuid');
});
}
};

View File

@ -1,41 +0,0 @@
<?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('predefined_mutations', function (Blueprint $table) {
// Drop old foreign key constraint first
$table->dropForeign(['dynamic_id']);
// Drop old columns
$table->dropColumn(['dynamic_id', 'type']);
// Add new ledger relationship (nullable to support pre-existing entries gracefully)
$table->foreignId('ledger_id')
->after('id')
->nullable()
->constrained()
->cascadeOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('predefined_mutations', function (Blueprint $table) {
$table->dropConstrainedForeignId('ledger_id');
$table->foreignId('dynamic_id')->after('id')->constrained()->cascadeOnDelete();
$table->string('type')->default('reward');
});
}
};

View File

@ -1,34 +0,0 @@
<?php
use App\Models\Chat;
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
{
Chat::unguard();
\App\Models\Chat::all()->each(function (Chat $chat) {
$newType = match($chat->chatable_type) {
\App\Models\Mutation::class => 'mutation',
\App\Models\Ledger::class => 'ledger',
\App\Models\Dynamic::class => 'dynamic',
default => $chat->chatable_type,
};
$chat->update(['chatable_type' => $newType]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@ -1,31 +0,0 @@
<?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('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};

View File

@ -1,42 +0,0 @@
<?php
use App\Models\Message;
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
{
\App\Models\Message::all()->each(function (Message $message) {
$msg = $message->content;
$msg = preg_replace_callback('/<user:([0-9]+)>/', function ($matches) {
$userId = $matches[1];
$user = \App\Models\User::find($userId);
if($user){
$userId = $user->uuid;
}
return "<user:$userId>";
}, $msg);
if($msg != $message->content) {
$message->update(['content' => $msg]);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('uuid_base', function (Blueprint $table) {
//
});
}
};

View File

@ -2,23 +2,24 @@
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\Message;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\User; use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
/** /**
* Seed the application's database. * Seed the application's database.
*/ */
public function run(): void public function run(ActivityService $activityService): void
{ {
DB::transaction(function () { DB::transaction(function () use ($activityService) {
// 1. Create Core Users // 1. Create Core Users
$testUser = User::factory()->create([ $testUser = User::factory()->create([
'name' => 'Test User', 'name' => 'Test User',
@ -52,41 +53,24 @@ class DatabaseSeeder extends Seeder
// ---------------------------------------------------- // ----------------------------------------------------
$velvetSanctuary = Dynamic::create([ $velvetSanctuary = Dynamic::create([
'name' => 'The Velvet Sanctuary', 'name' => 'The Velvet Sanctuary',
'rules' => "1. Respect limits and boundaries at all times.\n2. Submit daily logs for curfew and chores.\n3. Maintain proper protocol in the general discussion.", 'rules' => "1. Respect limits and boundaries at all times.
2. Submit daily logs for curfew and chores.
3. Maintain proper protocol in the general discussion.",
]); ]);
// 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', 'display_name' => 'The Master']); $velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']); $velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant', 'display_name' => 'Bitch Boi']); $velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant']);
// 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;
// Seed Dynamic Chat Messages // Seed Dynamic Chat Messages
Message::create([ $activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.');
'chat_id' => $velvetChat->id, $activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.');
'user_id' => $alice->id, $activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now.");
'content' => 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.', $activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.');
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $testUser->id,
'content' => 'I will review the curfew log later today.',
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $bob->id,
'content' => "Yes, Sir. Yes, Ma'am. I am starting on the chores now.",
]);
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $testUser->id,
'content' => 'Excellent. Keep up the high standard.',
]);
// Add Ledgers // Add Ledgers
$curfewLedger = Ledger::create([ $curfewLedger = Ledger::create([
@ -114,144 +98,37 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
Mutation::create([ $activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved');
'ledger_id' => $curfewLedger->id, $activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved');
'user_id' => $bob->id, $activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved');
'type' => 'reward',
'amount' => 10,
'description' => 'Checked in by 10:45 PM on Friday',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $curfewLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 15,
'description' => 'Checked in by 10:30 PM on Saturday',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $curfewLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Checked in by 10:50 PM on Sunday',
'status' => 'approved',
]);
// Seed Cleaning Mutations // Seed Cleaning Mutations
Mutation::create([ $activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved');
'ledger_id' => $cleaningLedger->id, $activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved');
'user_id' => $bob->id, $activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved');
'type' => 'reward', $activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved');
'amount' => 15,
'description' => 'Deep cleaning of the main chamber',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 20,
'description' => 'Arranged gear rack and polished leather accessories',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Mopped obsidian floors',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $alice->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Left keys in the locks unmonitored',
'status' => 'approved',
]);
// Seed Pending Mutation with its own Chat Messages! // Seed Pending Mutation with its own Chat Messages!
$pendingMutation = Mutation::create([ $pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending');
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'addition',
'amount' => 10,
'description' => 'Weekly chore submission - dusting shelves',
'status' => 'pending',
]);
// Pending mutation chat messages (chat is auto-created on booted)
$pendingMutationChat = $pendingMutation->chat; $pendingMutationChat = $pendingMutation->chat;
Message::create([ $activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.');
'chat_id' => $pendingMutationChat->id, $activityService->createMessage($pendingMutationChat, $alice, "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.");
'user_id' => $bob->id, $activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!');
'content' => 'I have finished the shelves. Please approve when convenient.',
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $alice->id,
'content' => "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.",
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $bob->id,
'content' => 'Apologies, Ma\'am. I will re-wipe the top section immediately!',
]);
// Seed Etiquette Mutations // Seed Etiquette Mutations
Mutation::create([ $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved');
'ledger_id' => $etiquetteLedger->id, $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved');
'user_id' => $bob->id, $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved');
'type' => 'penalty', $activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved');
'amount' => 5,
'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => 10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => 5,
'description' => 'Spoke out of turn in general chat',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => -5,
'description' => 'Excellent reciting of the house codes',
'status' => 'approved',
]);
// ---------------------------------------------------- // ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement // 3. Seed Dynamic 2: Obsidian Household Agreement
// ---------------------------------------------------- // ----------------------------------------------------
$obsidianHousehold = Dynamic::create([ $obsidianHousehold = Dynamic::create([
'name' => 'Obsidian Household Agreement', 'name' => 'Obsidian Household Agreement',
'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.", 'rules' => "1. All residents must do their fair share of maintenance.
2. Coffee machine must be refilled immediately when empty.",
]); ]);
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']); $obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
@ -260,29 +137,10 @@ class DatabaseSeeder extends Seeder
$obsidianChat = $obsidianHousehold->chat; $obsidianChat = $obsidianHousehold->chat;
Message::create([ $activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?");
'chat_id' => $obsidianChat->id, $activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea.");
'user_id' => $alice->id, $activityService->createMessage($obsidianChat, $testUser, 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.');
'content' => "Who finished the coffee beans and didn't put them on the shopping list?", $activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.');
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $charles->id,
'content' => "Wasn't me, I only drink tea.",
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $testUser->id,
'content' => 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.',
]);
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $alice->id,
'content' => 'Thank you, Test User. Appreciate the honesty.',
]);
// Add Ledgers // Add Ledgers
$kitchenLedger = Ledger::create([ $kitchenLedger = Ledger::create([
@ -302,33 +160,11 @@ class DatabaseSeeder extends Seeder
]); ]);
// Seed Chores Mutations // Seed Chores Mutations
Mutation::create([ $activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved');
'ledger_id' => $kitchenLedger->id, $activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved');
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 25,
'description' => 'Emptied and loaded the dishwasher',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $kitchenLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 15,
'description' => 'Took out recycling and trash bags',
'status' => 'approved',
]);
// Seed Coffee Mutations // Seed Coffee Mutations
Mutation::create([ $activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved');
'ledger_id' => $coffeeLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Descaled and refilled coffee beans',
'status' => 'approved',
]);
}); });
} }
} }

226
package-lock.json generated
View File

@ -636,9 +636,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@lucide/vue": { "node_modules/@lucide/vue": {
"version": "1.21.0", "version": "1.18.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.21.0.tgz", "resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz",
"integrity": "sha512-eoFn3tppjKAc12ZqdnRSMFdtwQ1ZMRQFb6SV1Eub6Y8kU28ccnqKeSFdnur9hMg8gIbosU2Y3WFJr/J/xS/IlQ==", "integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==",
"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.1", "version": "3.17.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
"integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
"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.29", "version": "3.13.28",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.29.tgz", "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.28.tgz",
"integrity": "sha512-MWb9tNHjpar3sP34b8+3A4I5j9akveoPXIYqqp7/ipyWd49a/kso+1S1LqEmAVR/+g/k1WWTJC4ktvdCGWgXYQ==", "integrity": "sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/virtual-core": "3.17.1" "@tanstack/virtual-core": "3.17.0"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@ -1354,9 +1354,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.0", "version": "22.19.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
"integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==",
"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.1", "@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/type-utils": "8.61.0",
"@typescript-eslint/utils": "8.61.1", "@typescript-eslint/utils": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.0",
"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.1", "@typescript-eslint/parser": "^8.61.0",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.0",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.1", "@typescript-eslint/types": "^8.61.0",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.1" "@typescript-eslint/visitor-keys": "8.61.0"
}, },
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz",
"integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.1", "@typescript-eslint/utils": "8.61.0",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.0",
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz",
"integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==",
"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.1", "@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.1" "@typescript-eslint/typescript-estree": "8.61.0"
}, },
"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.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.61.1", "@typescript-eslint/types": "8.61.0",
"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.9.0", "version": "14.8.0",
"resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.9.0.tgz", "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.8.0.tgz",
"integrity": "sha512-E3j9hDlfVf10F30MRcLTPY2IIhWIx1nsvkVukk14kTcuA+oBVot9zsP1hzsO+PAMDxV3Fd9FimBJtUBNBL5KFA==", "integrity": "sha512-yIquzhXH7ZsrwSSm+rYvoGCRY6wcuF4qBi76e0l7hHLq7YU0f9aC+RcR5fL+XJNfmBZxgX5cVl4sppt4x7ZCBg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2044,9 +2044,6 @@
"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"
}, },
@ -2642,15 +2639,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concurrently": { "node_modules/concurrently": {
"version": "9.2.3", "version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.3.tgz", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-ihjs0E2SxvDgq/MK418hX6YycQgKhsqxpbZuZbHo0yKfqDWdymWMjWYIpCIzqDDLLKClHlXev8whW/8WXmJ0BA==", "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"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.4", "shell-quote": "1.8.3",
"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"
@ -2967,25 +2964,6 @@
"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",
@ -3049,17 +3027,15 @@
} }
}, },
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.1", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.1.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
"integrity": "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==",
"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.1.0", "is-date-object": "^1.0.5",
"is-symbol": "^1.1.1" "is-symbol": "^1.0.4"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -3069,9 +3045,9 @@
} }
}, },
"node_modules/es-toolkit": { "node_modules/es-toolkit": {
"version": "1.48.1", "version": "1.47.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.48.1.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.1.tgz",
"integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", "integrity": "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q==",
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
"docs", "docs",
@ -5114,9 +5090,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -5711,9 +5687,9 @@
} }
}, },
"node_modules/reka-ui": { "node_modules/reka-ui": {
"version": "2.10.0", "version": "2.9.10",
"resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.10.0.tgz", "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz",
"integrity": "sha512-HIUVfSBM/AyGkcUI7aiOxxMc4N+0UD2ZEun8dcrT0H4fveotEoeDdvzyZu97eeEvEa1H9oGHoOpApkfxlgnC7g==", "integrity": "sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
@ -5961,9 +5937,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -6046,9 +6022,9 @@
} }
}, },
"node_modules/shell-quote": { "node_modules/shell-quote": {
"version": "1.8.4", "version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -6543,16 +6519,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.61.1", "version": "8.61.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz",
"integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.61.1", "@typescript-eslint/eslint-plugin": "8.61.0",
"@typescript-eslint/parser": "8.61.1", "@typescript-eslint/parser": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/utils": "8.61.1" "@typescript-eslint/utils": "8.61.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
beStrictAboutTestsThatDoNotTestAnything="false"
colors="true"
processIsolation="false"
stopOnError="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
backupStaticProperties="false">
<testsuites>
<testsuite name="Browser Test Suite">
<directory suffix="Test.php">./tests/Browser</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@ -23,7 +23,7 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite" /> <env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/> <env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>

View File

@ -1,38 +0,0 @@
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);
}),
);
});

View File

@ -13,6 +13,5 @@
@import './components/password-input.css'; @import './components/password-input.css';
@import './components/auth-layout.css'; @import './components/auth-layout.css';
@import './components/chat.css'; @import './components/chat.css';
@import "./components/lightbox.css"; @import './components/lightbox.css';
@import "./components/invite-form.css"; @import './components/invite-form.css';
/*@import "./components/display-name-form.css";*/

View File

@ -10,21 +10,8 @@
.c-chat__list { .c-chat__list {
@apply mt-4 flex flex-col gap-3; @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 { .c-chat__message {
@apply p-4 shadow-sm sm:rounded-lg; @apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border); border: 1px solid var(--border);
&.c-chat__message--system { &.c-chat__message--system {
@ -96,7 +83,7 @@
.c-chat__message-author { .c-chat__message-author {
@apply font-semibold; @apply font-semibold;
/*color: var(--foreground);*/ color: var(--foreground);
} }
.c-chat__message-time { .c-chat__message-time {
@ -221,4 +208,3 @@
} }
} }
} }
.c-chat__user-link { @apply font-semibold text-blue-500 hover:underline; }

View File

@ -49,7 +49,3 @@ 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');
}

View File

@ -2,46 +2,17 @@
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const props = withDefaults( const props = defineProps<{
defineProps<{ dynamicId: number;
dynamicId: string; ledgerId: number;
ledgerId: string; }>();
predefinedMutations?: Array<{
id: string;
name: string;
description: string | null;
amount: number;
}>;
}>(),
{
predefinedMutations: () => [],
}
);
const form = useForm({ const form = useForm({
amount: 0, amount: 0,
description: '', description: '',
predefined_mutation_id: null as string | null,
media: [] as File[], media: [] as File[],
}); });
function selectPredefinedMutation(event: Event) {
const select = event.target as HTMLSelectElement;
const selectedId = select.value;
if (!selectedId) {
form.predefined_mutation_id = null;
form.amount = 0;
form.description = '';
return;
}
const mutation = props.predefinedMutations.find(m => m.id === selectedId);
if (mutation) {
form.predefined_mutation_id = mutation.id;
form.amount = mutation.amount;
form.description = mutation.description || mutation.name;
}
}
function handleMutationFileChange(event: Event) { function handleMutationFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
@ -73,28 +44,6 @@ function submit() {
<div class="c-add-mutation-form"> <div class="c-add-mutation-form">
<h4 class="c-add-mutation-form__title">Add Mutation</h4> <h4 class="c-add-mutation-form__title">Add Mutation</h4>
<form @submit.prevent="submit" class="c-add-mutation-form__form"> <form @submit.prevent="submit" class="c-add-mutation-form__form">
<!-- Predefined Templates Selection -->
<div v-if="predefinedMutations && predefinedMutations.length > 0" class="c-add-mutation-form__field">
<label for="predefined_mutation" class="c-add-mutation-form__label"
>Apply Predefined Template</label
>
<select
id="predefined_mutation"
class="c-add-mutation-form__select"
@change="selectPredefinedMutation"
>
<option value="">-- Choose a predefined template (optional) --</option>
<option
v-for="item in predefinedMutations"
:key="item.id"
:value="item.id"
>
{{ item.name }} ({{ item.amount >= 0 ? '+' : '' }}{{ item.amount }} points)
</option>
</select>
</div>
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label for="amount" class="c-add-mutation-form__label" <label for="amount" class="c-add-mutation-form__label"
>Amount</label >Amount</label
@ -104,7 +53,6 @@ function submit() {
id="amount" id="amount"
type="number" type="number"
class="c-add-mutation-form__input" class="c-add-mutation-form__input"
data-test="amount-input"
/> />
<div <div
v-if="form.errors.amount" v-if="form.errors.amount"
@ -123,7 +71,6 @@ function submit() {
id="description" id="description"
rows="4" rows="4"
class="c-add-mutation-form__textarea" class="c-add-mutation-form__textarea"
data-test="description-input"
></textarea> ></textarea>
<div <div
v-if="form.errors.description" v-if="form.errors.description"
@ -173,7 +120,6 @@ function submit() {
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="c-add-mutation-form__submit-btn" class="c-add-mutation-form__submit-btn"
data-test="add-mutation-button"
> >
Add Mutation Add Mutation
</button> </button>
@ -205,10 +151,6 @@ function submit() {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300; @apply block text-sm font-medium text-gray-700 dark:text-gray-300;
} }
.c-add-mutation-form__select {
@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__input { .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; @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;
} }

View File

@ -1,121 +1,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { usePage } from '@inertiajs/vue3'; import { ref, computed } from 'vue';
import { useForm, usePage } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue'; import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { ref, computed, watch } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import ChatInput from './chat/ChatInput.vue'; import { Paperclip, Info } from '@lucide/vue';
import ChatSystemMessage from './chat/ChatSystemMessage.vue';
import ChatUserMessage from './chat/ChatUserMessage.vue';
const props = withDefaults( const props = defineProps<{
defineProps<{ chat: {
chat: { id: number;
messages: Array<{
id: number; id: number;
messages?: Array<{ user: { id: number; name: string };
content: string;
created_at: string;
media?: Array<{
id: number; id: number;
user: { id: number; name: string } | null; url: string;
content: string; file_name: string;
created_at: string; mime_type: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
}>; }>;
};
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({
@ -133,40 +39,47 @@ if (!echoIsConfigured()) {
}); });
} }
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({
content: '',
media: [] as File[],
});
useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => { useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
messages.value.push(e.message); props.chat.messages.push(e.message);
}); });
const participantsById = computed(() => { function handleFileChange(event: Event) {
const list = props.participants || []; const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
return list.reduce( function removeFile(index: number) {
(acc, p) => { form.media.splice(index, 1);
acc[p.id] = p; }
return acc;
},
{} as Record<
number,
{
id: string;
name: string;
pivot?: { display_name: string | null } | null;
}
>,
);
});
const currentUser = computed(() => usePage().props.auth?.user); const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number | null): boolean { function isOwnMessage(messageUserId: number): boolean {
if (messageUserId === null) {
return false;
}
return currentUser.value && currentUser.value.id === messageUserId; return currentUser.value && currentUser.value.id === messageUserId;
} }
function submit() {
form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
});
}
// Lightbox Modal state // Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null); const activeLightboxUrl = ref<string | null>(null);
const activeLightboxType = ref<'image' | 'video' | null>(null); const activeLightboxType = ref<'image' | 'video' | null>(null);
@ -188,47 +101,154 @@ 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 messages" v-for="message in chat.messages"
:key="message.id" :key="message.id"
:class="[ :class="[
'c-chat__message', 'c-chat__message',
{ {
'c-chat__message--system': message.user === null, 'c-chat__message--system':
'c-chat__message--own': isOwnMessage(message.user?.id), message.user.id === 0,
'c-chat__message--other': message.user !== null && !isOwnMessage(message.user?.id) 'c-chat__message--own': isOwnMessage(message.user.id),
'c-chat__message--other': !isOwnMessage(
message.user.id,
),
}, },
]" ]"
> >
<!-- Standard User Chat Message --> <!-- Standard User Chat Message -->
<ChatUserMessage <template v-if="!message.content.startsWith('System:')">
v-if="message.user" <div class="c-chat__message-header">
:message="message" <span class="c-chat__message-author">{{
:participants-by-id="participantsById" message.user.name
:dynamic-id="dynamicId" }}</span>
@open-lightbox="openLightbox" <span class="c-chat__message-time">{{
/> new Date(message.created_at).toLocaleString()
}}</span>
</div>
<p class="c-chat__message-text">
{{ message.content }}
</p>
<!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div
v-for="item in message.media"
:key="item.id"
class="c-chat__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
:alt="item.file_name"
class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
>
<video
:src="item.url"
class="c-chat__video"
></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div>
</template>
<!-- Subtle Activity Log System Message --> <!-- Subtle Activity Log System Message -->
<ChatSystemMessage <template v-else>
v-else <div class="c-chat__system-inner">
:message="message" <Info class="c-chat__system-icon" />
:participants-by-id="participantsById" <span class="c-chat__system-text">
:dynamic-id="dynamicId" {{ message.content.replace(/^System:\s*/, '') }}
/> </span>
<span class="c-chat__system-time">
{{
new Date(message.created_at).toLocaleTimeString(
[],
{ hour: '2-digit', minute: '2-digit' },
)
}}
</span>
</div>
</template>
</div> </div>
<div v-if="messages.length === 0" class="c-chat__empty"> <div v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
</div> </div>
</div> </div>
<form @submit.prevent="submit" class="c-chat__form">
<div class="c-chat__form-group">
<label for="content" class="c-chat__label">Message</label>
<textarea
v-model="form.content"
id="content"
rows="3"
class="c-chat__textarea"
placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
@keydown.enter.exact.prevent="submit"
></textarea>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
</div>
<!-- Cohesive Chat input Form --> <!-- Attachment Button & Hidden Input -->
<ChatInput :chat-id="chat.id" /> <div class="c-chat__attachment-container">
<button
type="button"
@click="fileInput?.click()"
class="c-chat__attach-btn"
>
<Paperclip class="c-chat__attach-icon" />
Attach Photos/Videos
</button>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- Previews List -->
<div v-if="form.media.length > 0" class="c-chat__preview-list">
<div
v-for="(file, index) in form.media"
:key="index"
class="c-chat__preview-item"
>
<span class="c-chat__preview-name">{{
file.name
}}</span>
<button
type="button"
@click="removeFile(index)"
class="c-chat__preview-remove"
>
</button>
</div>
</div>
</div>
<div class="c-chat__submit-box">
<button
type="submit"
:disabled="form.processing"
class="c-chat__button"
>
Send
</button>
</div>
</form>
<!-- Gorgeous Dark Lightbox Modal --> <!-- Gorgeous Dark Lightbox Modal -->
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox"> <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
@ -250,183 +270,3 @@ function closeLightbox() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply flex flex-col h-[500px];
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-chat__title {
@apply p-4 font-bold border-b text-sm;
border-color: var(--border);
color: var(--foreground);
}
.c-chat__list {
@apply flex-1 overflow-y-auto p-4 space-y-4;
}
.c-chat__load-more {
@apply flex justify-center py-2;
}
.c-chat__load-more-btn {
@apply text-xs font-semibold text-blue-500 hover:underline;
}
.c-chat__message {
@apply max-w-[75%] p-3 rounded-lg flex flex-col gap-1;
}
.c-chat__message--own {
@apply self-end;
background-color: var(--primary);
color: var(--primary-foreground);
border-bottom-right-radius: 0;
.c-chat__message-author {
@apply hidden;
}
.c-chat__message-time {
@apply text-blue-100;
}
.c-chat__message-text {
color: var(--primary-foreground);
}
}
.c-chat__message--other {
@apply self-start;
background-color: var(--muted);
color: var(--muted-foreground);
border-bottom-left-radius: 0;
}
.c-chat__message--system {
@apply self-center max-w-full w-full bg-transparent border-0 p-0 text-center gap-0;
}
.c-chat__message-header {
@apply flex items-baseline gap-2 mb-1;
}
.c-chat__message-author {
@apply font-semibold text-xs;
}
.c-chat__message-time {
@apply text-[10px];
color: var(--muted-foreground);
}
.c-chat__message-text {
@apply text-sm break-words whitespace-pre-wrap leading-relaxed;
}
.c-chat__message-media {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply max-w-[120px] overflow-hidden rounded border border-black dark:border-gray-600 bg-black;
}
.c-chat__image {
@apply h-auto max-h-[80px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[80px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-lg font-bold text-white;
}
.c-chat__system-inner {
@apply inline-flex items-center gap-2 bg-neutral-100 dark:bg-neutral-900/30 px-3 py-1 rounded-full text-xs text-neutral-500 dark:text-neutral-400;
}
.c-chat__system-icon {
@apply size-3.5;
}
.c-chat__system-text {
@apply font-medium leading-relaxed;
}
.c-chat__system-time {
@apply text-[10px] opacity-75 ml-1;
}
.c-chat__empty {
@apply text-center text-xs py-8;
color: var(--muted-foreground);
}
.c-chat__form {
@apply p-4 border-t flex flex-col gap-2 bg-neutral-50 dark:bg-neutral-900/10;
border-color: var(--border);
}
.c-chat__form-group {
@apply relative flex flex-col gap-1;
}
.c-chat__label {
@apply sr-only;
}
.c-chat__textarea {
@apply w-full rounded border p-2 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 dark:bg-neutral-900 dark:border-neutral-800;
color: var(--foreground);
border-color: var(--border);
}
.c-chat__error {
@apply text-xs text-red-500 mt-1;
}
.c-chat__attachment-container {
@apply flex items-center mt-1;
}
.c-chat__attach-btn {
@apply inline-flex items-center gap-1.5 text-xs text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors;
}
.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 inline-flex items-center gap-2 bg-neutral-100 dark:bg-neutral-900/50 px-2 py-1 rounded text-xs;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate text-neutral-600 dark:text-neutral-400;
}
.c-chat__preview-remove {
@apply text-neutral-400 hover:text-red-500 transition-colors;
}
.c-chat__submit-box {
@apply flex justify-end mt-1;
}
.c-chat__button {
@apply inline-flex items-center justify-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>

View File

@ -1,34 +0,0 @@
<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:([0-9a-f-]+)>/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>

View File

@ -1,47 +0,0 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import InputError from '@/components/InputError.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
pivot: {
display_name: string | null;
};
};
}>();
const form = useForm({
display_name: props.dynamic.pivot?.display_name ?? '',
});
function submit() {
form.put(route('dynamics.participant.update', props.dynamic.id), {
preserveScroll: true,
});
}
</script>
<template>
<form @submit.prevent="submit" class="flex flex-col sm:flex-row items-start sm:items-center gap-4 p-4 border rounded-lg bg-card text-card-foreground">
<div class="flex-1 w-full">
<div class="font-medium text-sm mb-1">{{ dynamic.name }}</div>
<Input
v-model="form.display_name"
class="w-full"
placeholder="Enter custom display name"
required
/>
<InputError class="mt-1" :message="form.errors.display_name" />
</div>
<div class="sm:self-end">
<Button type="submit" size="sm" :disabled="form.processing">
Save
</Button>
</div>
</form>
</template>

View File

@ -3,9 +3,9 @@ import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineProps<{ defineProps<{
dynamicId: string; dynamicId: number;
ledgers: Array<{ ledgers: Array<{
id: string; id: number;
name: string; name: string;
score: number; score: number;
alignment: string; alignment: string;

View File

@ -1,22 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import Chat from '@/components/Chat.vue';
const props = defineProps<{ const props = defineProps<{
dynamicId: string; dynamicId: number;
ledgerId: string; ledgerId: number;
ledgerAlignment?: string; ledgerAlignment?: string;
mutations: Array<{ mutations: Array<{
id: string; // Now a UUID id: number;
user_id: number; user_id: number;
user: { name: string }; user: { name: string };
amount: number; amount: number;
description: string; description: string;
status: string; status: string;
created_at: string; created_at: string;
chat: any;
media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
participants?: Array<{
id: number;
name: string;
pivot?: { role: string };
}>;
isOwner: boolean;
}>(); }>();
const emit = defineEmits<{
(e: 'open-lightbox', url: string, mimeType: string): void;
}>();
function updateStatus(mutationId: number, status: 'approved' | 'rejected') {
useForm({ status }).put(
route('dynamics.ledgers.mutations.update', {
dynamic: props.dynamicId,
ledger: props.ledgerId,
mutation: mutationId,
}),
);
}
function isOwnerUser(userId: number): boolean {
const participant = props.participants?.find((p) => p.id === userId);
return participant?.pivot?.role === 'owner';
}
function getAmountClass(amount: number): string { function getAmountClass(amount: number): string {
const alignment = props.ledgerAlignment || 'neutral'; const alignment = props.ledgerAlignment || 'neutral';
@ -45,15 +74,18 @@ function getAmountClass(amount: number): string {
<li <li
v-for="mutation in mutations" v-for="mutation in mutations"
:key="mutation.id" :key="mutation.id"
class="c-mutation-list__item"
> >
<Link <div class="c-mutation-list__item-header">
:href="route('dynamics.ledgers.mutations.show', { dynamic: dynamicId, ledger: ledgerId, mutation: mutation.id })" <div>
class="c-mutation-list__item-link" <span class="c-mutation-list__item-author">
> {{
<div class="c-mutation-list__item-content"> isOwnerUser(mutation.user_id)
<p class="c-mutation-list__item-desc"> ? 'Added by'
{{ mutation.description }} : 'Suggested by'
</p> }}
{{ mutation.user.name }}
</span>
<div class="c-mutation-list__item-meta"> <div class="c-mutation-list__item-meta">
<span <span
:class="getAmountClass(mutation.amount)" :class="getAmountClass(mutation.amount)"
@ -62,8 +94,10 @@ function getAmountClass(amount: number): string {
{{ mutation.amount > 0 ? '+' : '' {{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }} }}{{ mutation.amount }}
</span> </span>
<span <!-- Only show status badge if mutation was NOT auto-approved by an owner -->
:class="{ <span
v-if="!isOwnerUser(mutation.user_id)"
:class="{
'c-mutation-list__item-status--pending': 'c-mutation-list__item-status--pending':
mutation.status === 'pending', mutation.status === 'pending',
'c-mutation-list__item-status--approved': 'c-mutation-list__item-status--approved':
@ -71,13 +105,76 @@ function getAmountClass(amount: number): string {
'c-mutation-list__item-status--rejected': 'c-mutation-list__item-status--rejected':
mutation.status === 'rejected', mutation.status === 'rejected',
}" }"
class="c-mutation-list__item-status" class="c-mutation-list__item-status"
> >
{{ mutation.status }} {{ mutation.status }}
</span> </span>
</div> </div>
</div> </div>
</Link> <div class="c-mutation-list__item-time">
{{ new Date(mutation.created_at).toLocaleString() }}
</div>
</div>
<p class="c-mutation-list__item-desc">
{{ mutation.description }}
</p>
<!-- Attached Mutation Proof Media -->
<div
v-if="mutation.media && mutation.media.length > 0"
class="c-mutation-list__media-list"
>
<div
v-for="item in mutation.media"
:key="item.id"
class="c-mutation-list__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
class="c-mutation-list__media-img"
@click="
emit('open-lightbox', item.url, item.mime_type)
"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="c-mutation-list__media-video-wrapper"
@click="
emit('open-lightbox', item.url, item.mime_type)
"
>
<video
:src="item.url"
class="c-mutation-list__media-video"
></video>
<div class="c-mutation-list__media-video-overlay">
</div>
</div>
</div>
</div>
<!-- Owner Approve/Reject Actions -->
<div
v-if="isOwner && mutation.status === 'pending'"
class="c-mutation-list__actions"
>
<button
@click="updateStatus(mutation.id, 'approved')"
class="c-mutation-list__approve-btn"
>
Approve
</button>
<button
@click="updateStatus(mutation.id, 'rejected')"
class="c-mutation-list__reject-btn"
>
Reject
</button>
</div>
<Chat :chat="mutation.chat" />
</li> </li>
</ul> </ul>
<div v-if="mutations.length === 0" class="c-mutation-list__empty"> <div v-if="mutations.length === 0" class="c-mutation-list__empty">
@ -98,23 +195,23 @@ function getAmountClass(amount: number): string {
} }
.c-mutation-list__list { .c-mutation-list__list {
@apply mt-4 space-y-2; @apply mt-4 space-y-4;
} }
.c-mutation-list__item-link { .c-mutation-list__item {
@apply block overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800 transition-all duration-200 hover:shadow-md hover:bg-gray-50 dark:hover:bg-gray-700; @apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
} }
.c-mutation-list__item-content { .c-mutation-list__item-header {
@apply flex items-center justify-between; @apply flex items-start justify-between;
} }
.c-mutation-list__item-desc { .c-mutation-list__item-author {
@apply text-sm text-gray-600 dark:text-gray-400; @apply font-semibold;
} }
.c-mutation-list__item-meta { .c-mutation-list__item-meta {
@apply flex items-center gap-2; @apply mt-1 flex items-center gap-2;
} }
.c-mutation-list__item-amount { .c-mutation-list__item-amount {
@ -149,6 +246,50 @@ function getAmountClass(amount: number): string {
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400; @apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
} }
.c-mutation-list__item-time {
@apply text-xs text-gray-500;
}
.c-mutation-list__item-desc {
@apply mt-3 text-sm text-gray-600 dark:text-gray-400;
}
.c-mutation-list__media-list {
@apply mt-3 flex flex-wrap gap-2;
}
.c-mutation-list__media-item {
@apply max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-mutation-list__media-img {
@apply h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video {
@apply h-auto max-h-[150px] w-full;
}
.c-mutation-list__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-mutation-list__actions {
@apply mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700;
}
.c-mutation-list__approve-btn {
@apply inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500;
}
.c-mutation-list__reject-btn {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
}
.c-mutation-list__empty { .c-mutation-list__empty {
@apply mt-4 text-gray-500; @apply mt-4 text-gray-500;
} }

View File

@ -1,15 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineProps<{ defineProps<{
dynamicId: string;
participants: Array<{ participants: Array<{
id: string; id: number;
name: string; name: string;
pivot: {
display_name: string | null;
};
}>; }>;
}>(); }>();
</script> </script>
@ -23,12 +16,7 @@ defineProps<{
:key="participant.id" :key="participant.id"
class="c-participants-list__item" class="c-participants-list__item"
> >
<Link {{ participant.name }}
:href="route('dynamics.users.show', [dynamicId, participant.id])"
class="block"
>
{{ participant.pivot.display_name ?? participant.name }}
</Link>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -1,112 +0,0 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Paperclip } from '@lucide/vue';
import { ref } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{
chatId: number;
}>();
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({
content: '',
media: [] as File[],
});
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeFile(index: number) {
form.media.splice(index, 1);
}
function submit() {
form.post(route('chats.messages.store', props.chatId), {
preserveScroll: true,
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
});
}
</script>
<template>
<form @submit.prevent="submit" class="c-chat__form">
<div class="c-chat__form-group">
<label for="content" class="c-chat__label">Message</label>
<textarea
v-model="form.content"
id="content"
rows="3"
class="c-chat__textarea"
placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
@keydown.enter.exact.prevent="submit"
></textarea>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
</div>
<!-- Attachment Button & Hidden Input -->
<div class="c-chat__attachment-container">
<button
type="button"
@click="fileInput?.click()"
class="c-chat__attach-btn"
>
<Paperclip class="c-chat__attach-icon" />
Attach Photos/Videos
</button>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- Previews List -->
<div v-if="form.media.length > 0" class="c-chat__preview-list">
<div
v-for="(file, index) in form.media"
:key="index"
class="c-chat__preview-item"
>
<span class="c-chat__preview-name">{{
file.name
}}</span>
<button
type="button"
@click="removeFile(index)"
class="c-chat__preview-remove"
>
</button>
</div>
</div>
</div>
<div class="c-chat__submit-box">
<button
type="submit"
:disabled="form.processing"
class="c-chat__button"
>
Send
</button>
</div>
</form>
</template>

View File

@ -1,108 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { route } from 'ziggy-js';
import { Info } from '@lucide/vue';
const props = defineProps<{
message: {
id: number;
content: string;
created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
};
participantsById: Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>;
dynamicId: string;
}>();
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 parsedContent = computed(() => {
let content = props.message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:([0-9a-f-]+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = props.participantsById[(userId)];
if (user) {
const url = route('dynamics.users.show', [props.dynamicId, (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 (props.message.subject_id && props.message.subject_type) {
if (
props.message.subject_type === 'mutation' ||
props.message.subject_type === 'ledger'
) {
const ledgerId =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger_id
: props.message.subject?.id;
const ledgerName =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger?.name
: props.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;
});
</script>
<template>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span
class="c-chat__system-text"
v-html="parsedContent"
></span>
<span
class="c-chat__system-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div>
</template>

View File

@ -1,151 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { route } from 'ziggy-js';
const props = defineProps<{
message: {
id: number;
user: { id: number; name: string } | null;
content: string;
created_at: string;
subject_id?: number | null;
subject_type?: string | null;
subject?: any;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
};
participantsById: Record<
number,
{
id: number;
name: string;
pivot?: { display_name: string | null } | null;
}
>;
dynamicId: string;
}>();
const emit = defineEmits<{
(e: 'open-lightbox', url: string, mimeType: string): void;
}>();
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 parsedContent = computed(() => {
let content = props.message.content;
// 1. Replace <user:id> placeholders with links to their dynamic profile
const userRegex = /<user:([0-9a-f-]+)>/g;
content = content.replace(userRegex, (match, userId) => {
const user = props.participantsById[(userId)];
if (user) {
const url = route('dynamics.users.show', [
props.dynamicId,
(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 (props.message.subject_id && props.message.subject_type) {
if (
props.message.subject_type === 'mutation' ||
props.message.subject_type === 'ledger'
) {
const ledgerId =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger_id
: props.message.subject?.id;
const ledgerName =
props.message.subject_type === 'mutation'
? props.message.subject?.ledger?.name
: props.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;
});
</script>
<template>
<div>
<div class="c-chat__message-header">
<span class="c-chat__message-author">{{ message.user?.name }}</span>
<span
class="c-chat__message-time"
:title="formatTimestamp(message.created_at).full"
>
{{ formatTimestamp(message.created_at).time }}
</span>
</div>
<p class="c-chat__message-text" v-html="parsedContent"></p>
<!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div
v-for="item in message.media"
:key="item.id"
class="c-chat__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
:alt="item.file_name"
class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
@click="emit('open-lightbox', item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="emit('open-lightbox', item.url, item.mime_type)"
>
<video :src="item.url" class="c-chat__video"></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,72 +0,0 @@
import { ref } from 'vue';
export function usePushNotifications() {
const isSubscribed = ref(false);
async function subscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
document.querySelector('meta[name="vapid-public-key"]')?.getAttribute('content') || '',
),
});
await fetch('/subscriptions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify(subscription),
});
isSubscribed.value = true;
}
async function unsubscribe() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await fetch('/subscriptions/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
},
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
isSubscribed.value = false;
}
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
return {
isSubscribed,
subscribe,
unsubscribe,
};
}

View File

@ -1,31 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLayout from '@/layouts/app/AppSidebarLayout.vue'; import AppLayout from '@/layouts/app/AppSidebarLayout.vue';
import type { BreadcrumbItem } from '@/types'; import type { BreadcrumbItem } from '@/types';
import { usePushNotifications } from '@/composables/usePushNotifications';
import { computed } from 'vue';
import { usePage } from '@inertiajs/vue3';
const props = defineProps<{ const { breadcrumbs = [] } = defineProps<{
breadcrumbs?: BreadcrumbItem[]; breadcrumbs?: BreadcrumbItem[];
}>(); }>();
const resolvedBreadcrumbs = computed(() => {
return props.breadcrumbs || (usePage().props.breadcrumbs as BreadcrumbItem[]) || [];
});
const { isSubscribed, subscribe, unsubscribe } = usePushNotifications();
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="resolvedBreadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<slot /> <slot />
<div class="fixed bottom-4 right-4">
<button
@click="isSubscribed ? unsubscribe() : subscribe()"
class="rounded-full bg-blue-500 px-4 py-2 text-white"
>
{{ isSubscribed ? 'Unsubscribe' : 'Subscribe' }}
</button>
</div>
</AppLayout> </AppLayout>
</template> </template>

View File

@ -14,9 +14,10 @@ defineOptions({
}); });
defineProps<{ defineProps<{
unreadDynamics: Array<{ unreadEntities: Array<{
id: number; id: number;
name: string; name: string;
type: 'Dynamic' | 'Ledger';
url: string; url: string;
unread_count: number; unread_count: number;
context_activities: Array<{ context_activities: Array<{
@ -50,29 +51,34 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__container"> <div class="c-dashboard__container">
<h2 class="c-dashboard__title">Recent Activity</h2> <h2 class="c-dashboard__title">Recent Activity</h2>
<div v-if="unreadDynamics.length > 0" class="c-dashboard__grid"> <div v-if="unreadEntities.length > 0" class="c-dashboard__grid">
<div <div
v-for="dynamic in unreadDynamics" v-for="entity in unreadEntities"
:key="dynamic.id" :key="`${entity.type}_${entity.id}`"
class="c-dashboard__card" class="c-dashboard__card"
> >
<div class="c-dashboard__card-header"> <div class="c-dashboard__card-header">
<div class="c-dashboard__entity-meta"> <div class="c-dashboard__entity-meta">
<span <span
class="c-dashboard__badge-type c-dashboard__badge-type--dynamic" :class="[
'c-dashboard__badge-type',
entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger',
]"
> >
Dynamic {{ entity.type }}
</span> </span>
<span class="c-dashboard__unread-count"> <span class="c-dashboard__unread-count">
{{ dynamic.unread_count }} New {{ entity.unread_count }} New
</span> </span>
</div> </div>
<Link <Link
:href="dynamic.url" :href="entity.url"
class="c-dashboard__entity-link" class="c-dashboard__entity-link"
> >
<h3 class="c-dashboard__entity-title"> <h3 class="c-dashboard__entity-title">
{{ dynamic.name }} {{ entity.name }}
</h3> </h3>
</Link> </Link>
</div> </div>
@ -80,7 +86,7 @@ function formatTime(isoString: string): string {
<div class="c-dashboard__activity-list"> <div class="c-dashboard__activity-list">
<!-- Context / Read Activities --> <!-- Context / Read Activities -->
<div <div
v-for="activity in dynamic.context_activities" v-for="activity in entity.context_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read" class="c-dashboard__activity-item c-dashboard__activity-item--read"
> >
@ -101,15 +107,17 @@ function formatTime(isoString: string): string {
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
<div <div
v-if="dynamic.new_activities.length > 0" v-if="entity.context_activities.length > 0"
class="c-dashboard__divider" class="c-dashboard__divider"
> >
<span class="c-dashboard__divider-text">NEW</span> <span class="c-dashboard__divider-text"
>New Activity Below</span
>
</div> </div>
<!-- New / Unread Activities --> <!-- New / Unread Activities -->
<div <div
v-for="activity in dynamic.new_activities" v-for="activity in entity.new_activities"
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread" class="c-dashboard__activity-item c-dashboard__activity-item--unread"
> >
@ -121,6 +129,7 @@ function formatTime(isoString: string): string {
<span class="c-dashboard__activity-time"> <span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }} {{ formatTime(activity.created_at) }}
</span> </span>
<span class="c-dashboard__new-badge">NEW</span>
</div> </div>
<p class="c-dashboard__activity-desc"> <p class="c-dashboard__activity-desc">
{{ activity.content }} {{ activity.content }}

View File

@ -2,26 +2,22 @@
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({
layout: {
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: 'Create',
href: route('dynamics.create'),
},
],
},
});
const form = useForm({ const form = useForm({
name: '', name: '',
rules: '', rules: '',
}); });
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: 'Create',
href: route('dynamics.create'),
},
];
function submit() { function submit() {
form.post(route('dynamics.store')); form.post(route('dynamics.store'));
} }

View File

@ -1,25 +1,17 @@
<script setup lang="ts"> <script setup>
import { Head, Link } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({ defineProps({
layout: { dynamics: Array,
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
],
},
}); });
defineProps<{ const breadcrumbs = [
dynamics: Array<{ {
id: string; name: 'Dynamics',
name: string; href: route('dynamics.index'),
rules: string; },
}>; ];
}>();
</script> </script>
<template> <template>
@ -114,7 +106,6 @@ defineProps<{
.c-dynamics-index__item-desc { .c-dynamics-index__item-desc {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400; @apply mt-2 text-sm text-gray-600 dark:text-gray-400;
white-space: pre-line;
} }
.c-dynamics-index__empty { .c-dynamics-index__empty {

View File

@ -1,25 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Invite User',
href: route('dynamics.invitations.create', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -33,6 +15,21 @@ const form = useForm({
role: 'participant', role: 'participant',
}); });
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Invite User',
href: route('dynamics.invitations.create', props.dynamic.id),
},
];
function submit() { function submit() {
form.post(route('dynamics.invitations.store', props.dynamic.id), { form.post(route('dynamics.invitations.store', props.dynamic.id), {
onSuccess: () => form.reset(), onSuccess: () => form.reset(),

View File

@ -1,161 +0,0 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.participant.display_name ?? props.participant.name,
href: route('dynamics.users.show', [
props.dynamic.id,
props.participant.id,
]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
participant: {
id: number;
name: string;
display_name: string | null;
role: string;
};
mutations: Array<{
id: number;
amount: number;
description: string;
status: string;
created_at: string;
ledger: { id: number; name: string };
}>;
}>();
</script>
<template>
<Head :title="participant.display_name ?? participant.name" />
<div class="c-participant-show">
<div class="c-participant-show__container">
<h2 class="c-participant-show__title">
Activity for
{{ participant.display_name ?? participant.name }} ({{
participant.role.toUpperCase()
}}) in {{ dynamic.name }}
</h2>
<div class="c-participant-show__activity-list">
<div
v-for="mutation in mutations"
:key="mutation.id"
class="c-participant-show__activity-item"
>
<Link
:href="
route('dynamics.ledgers.show', [
dynamic.id,
mutation.ledger.id,
])
"
class="block"
>
<div class="c-participant-show__activity-meta">
<span class="c-participant-show__activity-time">
{{
new Date(
mutation.created_at,
).toLocaleString()
}}
</span>
<span
class="ml-2 font-semibold"
:class="
mutation.amount > 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
"
>
{{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }}
</span>
<span class="ml-2 text-neutral-400"
>on {{ mutation.ledger.name }}</span
>
<span
class="ml-auto rounded px-1.5 py-0.5 text-xs uppercase"
:class="
mutation.status === 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-950/30 dark:text-green-400'
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-400'
"
>
{{ mutation.status }}
</span>
</div>
<p class="c-participant-show__activity-desc">
{{ mutation.description }}
</p>
</Link>
</div>
<div
v-if="mutations.length === 0"
class="text-sm text-neutral-500"
>
No mutations recorded for this participant in this Dynamic
yet.
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-participant-show {
@apply py-12;
}
.c-participant-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-participant-show__title {
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
}
.c-participant-show__activity-list {
@apply space-y-4;
}
.c-participant-show__activity-item {
@apply rounded-lg border p-4 transition-colors hover:bg-neutral-50 dark:border-neutral-800 dark:hover:bg-neutral-900;
background-color: var(--card);
}
.c-participant-show__activity-meta {
@apply mb-1.5 flex items-center text-xs;
}
.c-participant-show__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-participant-show__activity-desc {
@apply mt-1 text-sm text-neutral-600 dark:text-neutral-400;
}
</style>

View File

@ -1,154 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
predefined_mutation: {
id: number;
name: string;
description: string;
amount: number;
type: string;
};
}>();
const form = useForm({
name: props.predefined_mutation.name,
description: props.predefined_mutation.description,
amount: props.predefined_mutation.amount,
type: props.predefined_mutation.type,
});
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Predefined Mutations',
href: route('dynamics.predefined-mutations.index', props.dynamic.id),
},
{
name: 'Edit',
href: route('dynamics.predefined-mutations.edit', [props.dynamic.id, props.predefined_mutation.id]),
},
];
function submit() {
form.put(route('dynamics.predefined-mutations.update', [props.dynamic.id, props.predefined_mutation.id]));
}
</script>
<template>
<Head title="Edit Predefined Mutation" />
<AppLayout :breadcrumbs="breadcrumbs">
<div class="c-predefined-mutation-edit">
<div class="c-predefined-mutation-edit__container">
<div class="c-predefined-mutation-edit__card">
<div class="c-predefined-mutation-edit__body">
<h3 class="c-predefined-mutation-edit__title">
Edit {{ predefined_mutation.name }}
</h3>
<form @submit.prevent="submit" class="c-predefined-mutation-edit__form">
<div class="c-predefined-mutation-edit__field">
<label for="name" class="c-predefined-mutation-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="description" class="c-predefined-mutation-edit__label">Description</label>
<textarea v-model="form.description" id="description" rows="4" class="c-predefined-mutation-edit__textarea"></textarea>
</div>
<div class="c-predefined-mutation-edit__field">
<label for="amount" class="c-predefined-mutation-edit__label">Amount</label>
<input v-model="form.amount" id="amount" type="number" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="type" class="c-predefined-mutation-edit__label">Type</label>
<select v-model="form.type" id="type" class="c-predefined-mutation-edit__select">
<option value="reward">Reward</option>
<option value="penalty">Penalty</option>
</select>
</div>
<div class="c-predefined-mutation-edit__actions">
<button type="submit" :disabled="form.processing" class="c-predefined-mutation-edit__submit-btn">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutation-edit {
@apply py-12;
}
.c-predefined-mutation-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutation-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutation-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutation-edit__title {
@apply text-lg font-medium;
}
.c-predefined-mutation-edit__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutation-edit__field {
@apply block;
}
.c-predefined-mutation-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutation-edit__input {
@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-predefined-mutation-edit__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-predefined-mutation-edit__select {
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm 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-predefined-mutation-edit__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutation-edit__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>

View File

@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { defineProps } from 'vue';
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -75,14 +76,6 @@ function submit() {
<div class="c-predefined-mutations__item-amount"> <div class="c-predefined-mutations__item-amount">
{{ mutation.amount }} {{ mutation.amount }}
</div> </div>
<div class="c-predefined-mutations__item-actions">
<Link :href="route('dynamics.predefined-mutations.edit', [dynamic.id, mutation.id])" class="c-predefined-mutations__item-action-btn">
Edit
</Link>
<Link :href="route('dynamics.predefined-mutations.destroy', [dynamic.id, mutation.id])" method="delete" as="button" class="c-predefined-mutations__item-action-btn c-predefined-mutations__item-action-btn--danger">
Delete
</Link>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -220,18 +213,6 @@ function submit() {
@apply text-lg font-semibold; @apply text-lg font-semibold;
} }
.c-predefined-mutations__item-actions {
@apply flex gap-2;
}
.c-predefined-mutations__item-action-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-3 py-1.5 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;
}
.c-predefined-mutations__item-action-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
.c-predefined-mutations__form { .c-predefined-mutations__form {
@apply mt-6 space-y-6; @apply mt-6 space-y-6;
} }

View File

@ -1,25 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3'; import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Settings',
href: route('dynamics.edit', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@ -34,6 +16,21 @@ const form = useForm({
rules: props.dynamic.rules, rules: props.dynamic.rules,
}); });
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Settings',
href: route('dynamics.edit', props.dynamic.id),
},
];
function submit() { function submit() {
form.patch(route('dynamics.update', props.dynamic.id)); form.patch(route('dynamics.update', props.dynamic.id));
} }
@ -73,6 +70,12 @@ function submit() {
</form> </form>
</div> </div>
</div> </div>
<div class="mt-8">
<InertiaLink :href="route('dynamics.predefined-mutations.index', dynamic.id)" class="c-dynamic-settings__submit-btn">
Manage Predefined Mutations
</InertiaLink>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -5,28 +5,13 @@ import LedgerList from '@/components/LedgerList.vue';
import { Head, Link as InertiaLink } from '@inertiajs/vue3'; import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.uuid),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: string; id: number;
name: string; name: string;
rules: string; rules: string;
chat: any; chat: any;
participants: Array<{ id: number; name: string, pivot: { display_name: string | null } }>; participants: Array<{ id: number; name: string }>;
ledgers: Array<{ ledgers: Array<{
id: number; id: number;
name: string; name: string;
@ -35,14 +20,19 @@ const props = defineProps<{
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
messages: { isOwner: boolean;
data: Array<any>;
next_page_url: string | null;
};
can: {
update: boolean;
}
}>(); }>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
];
</script> </script>
<template> <template>
@ -59,7 +49,7 @@ const props = defineProps<{
{{ dynamic.rules }} {{ dynamic.rules }}
</p> </p>
</div> </div>
<InertiaLink v-if="can.update" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn"> <InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
Settings Settings
</InertiaLink> </InertiaLink>
</div> </div>
@ -67,15 +57,15 @@ const props = defineProps<{
</div> </div>
<!-- Dynamic Chat --> <!-- Dynamic Chat -->
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" /> <Chat :chat="dynamic.chat" />
<!-- Participants Component --> <!-- Participants Component -->
<ParticipantsList :dynamic-id="dynamic.id" :participants="dynamic.participants" /> <ParticipantsList :participants="dynamic.participants" />
<!-- Ledgers List Component --> <!-- Ledgers List Component -->
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" /> <LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
<div v-if="can.update" class="mt-8 flex gap-4"> <div v-if="isOwner" class="mt-8 flex gap-4">
<InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn"> <InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn">
Invite User Invite User
</InertiaLink> </InertiaLink>
@ -117,7 +107,6 @@ const props = defineProps<{
.c-dynamic-show__rules { .c-dynamic-show__rules {
@apply mt-2 text-sm; @apply mt-2 text-sm;
color: var(--muted-foreground); color: var(--muted-foreground);
white-space: pre-line;
} }
.c-dynamic-show__settings-btn { .c-dynamic-show__settings-btn {

View File

@ -1,33 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
import CreateLedgerForm from '@/components/CreateLedgerForm.vue'; import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: 'Create Ledger',
href: route('dynamics.ledgers.create', props.dynamic.id),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
name: string; name: string;
}; };
}>(); }>();
const breadcrumbs = [
{
name: 'Dynamics',
href: route('dynamics.index'),
},
{
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: 'Create Ledger',
href: route('dynamics.ledgers.create', props.dynamic.id),
},
];
</script> </script>
<template> <template>

View File

@ -1,172 +0,0 @@
<script setup lang="ts">
import { Head, useForm, Link } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Edit',
href: route('dynamics.ledgers.edit', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
rules: string;
alignment: 'positive' | 'neutral' | 'negative';
};
}>();
const form = useForm({
name: props.ledger.name,
rules: props.ledger.rules,
alignment: props.ledger.alignment,
});
function submit() {
form.put(route('dynamics.ledgers.update', [props.dynamic.id, props.ledger.id]));
}
</script>
<template>
<Head title="Edit Ledger" />
<div class="c-ledger-edit">
<div class="c-ledger-edit__container">
<div class="c-ledger-edit__card">
<div class="c-ledger-edit__body">
<h3 class="c-ledger-edit__title">
Edit {{ ledger.name }}
</h3>
<form @submit.prevent="submit" class="c-ledger-edit__form">
<div class="c-ledger-edit__field">
<label for="name" class="c-ledger-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-ledger-edit__input" />
</div>
<div class="c-ledger-edit__field">
<label for="rules" class="c-ledger-edit__label">Rules</label>
<textarea v-model="form.rules" id="rules" rows="4" class="c-ledger-edit__textarea"></textarea>
</div>
<div class="c-ledger-edit__field">
<label for="alignment" class="c-ledger-edit__label">Alignment</label>
<select
v-model="form.alignment"
id="alignment"
class="c-ledger-edit__select"
>
<option value="positive">
Positive (Higher Score is Better)
</option>
<option value="neutral">
Neutral (Frictionless / Standard)
</option>
<option value="negative">
Negative (Lower Score is Better / Demerits)
</option>
</select>
</div>
<div class="c-ledger-edit__actions">
<button type="submit" :disabled="form.processing" class="c-ledger-edit__submit-btn">
Save
</button>
<Link
:href="route('dynamics.ledgers.close', [dynamic.id, ledger.id])"
method="put"
as="button"
class="c-ledger-edit__submit-btn c-ledger-edit__submit-btn--danger"
>
Close Ledger
</Link>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-ledger-edit {
@apply py-12;
}
.c-ledger-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-ledger-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-ledger-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-ledger-edit__title {
@apply text-lg font-medium;
}
.c-ledger-edit__form {
@apply mt-6 space-y-6;
}
.c-ledger-edit__field {
@apply block;
}
.c-ledger-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-ledger-edit__input {
@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-ledger-edit__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-ledger-edit__select {
@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-ledger-edit__actions {
@apply mt-6 flex items-center justify-end gap-4;
}
.c-ledger-edit__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;
}
.c-ledger-edit__submit-btn--danger {
@apply bg-red-600 hover:bg-red-500 focus:bg-red-500 active:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400 dark:focus:bg-red-400 dark:active:bg-red-600;
}
</style>

View File

@ -1,150 +0,0 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Predefined Mutations',
href: route('dynamics.ledgers.predefined-mutations.index', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Edit',
href: route('dynamics.ledgers.predefined-mutations.edit', [props.dynamic.id, props.ledger.id, props.predefined_mutation.uuid]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
};
predefined_mutation: {
id: number;
name: string;
description: string;
amount: number;
uuid: string;
};
}>();
const form = useForm({
name: props.predefined_mutation.name,
description: props.predefined_mutation.description,
amount: props.predefined_mutation.amount,
});
function submit() {
form.put(route('dynamics.ledgers.predefined-mutations.update', [props.dynamic.id, props.ledger.id, props.predefined_mutation.uuid]));
}
</script>
<template>
<Head title="Edit Predefined Mutation" />
<div class="c-predefined-mutation-edit">
<div class="c-predefined-mutation-edit__container">
<div class="c-predefined-mutation-edit__card">
<div class="c-predefined-mutation-edit__body">
<h3 class="c-predefined-mutation-edit__title">
Edit {{ predefined_mutation.name }} on {{ ledger.name }}
</h3>
<form @submit.prevent="submit" class="c-predefined-mutation-edit__form">
<div class="c-predefined-mutation-edit__field">
<label for="name" class="c-predefined-mutation-edit__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__field">
<label for="description" class="c-predefined-mutation-edit__label">Description</label>
<textarea v-model="form.description" id="description" rows="4" class="c-predefined-mutation-edit__textarea"></textarea>
</div>
<div class="c-predefined-mutation-edit__field">
<label for="amount" class="c-predefined-mutation-edit__label">Amount</label>
<input v-model="form.amount" id="amount" type="number" class="c-predefined-mutation-edit__input" />
</div>
<div class="c-predefined-mutation-edit__actions">
<button type="submit" :disabled="form.processing" class="c-predefined-mutation-edit__submit-btn">
Save
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutation-edit {
@apply py-12;
}
.c-predefined-mutation-edit__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutation-edit__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutation-edit__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutation-edit__title {
@apply text-lg font-medium;
}
.c-predefined-mutation-edit__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutation-edit__field {
@apply block;
}
.c-predefined-mutation-edit__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutation-edit__input {
@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-predefined-mutation-edit__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-predefined-mutation-edit__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutation-edit__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>

View File

@ -1,266 +0,0 @@
<script setup lang="ts">
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import { defineProps } from 'vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
{
title: 'Predefined Mutations',
href: route('dynamics.ledgers.predefined-mutations.index', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
ledger: {
id: number;
name: string;
};
predefined_mutations: Array<{
id: number;
name: string;
description: string;
amount: number;
uuid: string;
}>;
}>();
const form = useForm({
name: '',
description: '',
amount: 0,
});
function submit() {
form.post(route('dynamics.ledgers.predefined-mutations.store', [props.dynamic.id, props.ledger.id]), {
onSuccess: () => form.reset(),
});
}
function destroy(uuid: string) {
if (confirm('Are you sure you want to delete this predefined mutation?')) {
const deleteForm = useForm({});
deleteForm.delete(route('dynamics.ledgers.predefined-mutations.destroy', [props.dynamic.id, props.ledger.id, uuid]));
}
}
</script>
<template>
<Head title="Predefined Mutations" />
<div class="c-predefined-mutations">
<div class="c-predefined-mutations__container">
<div class="c-predefined-mutations__card">
<div class="c-predefined-mutations__body">
<h3 class="c-predefined-mutations__title">
Predefined Mutations for {{ ledger.name }}
</h3>
<div class="c-predefined-mutations__list">
<div
v-for="mutation in predefined_mutations"
:key="mutation.id"
class="c-predefined-mutations__item"
>
<div class="c-predefined-mutations__item-details">
<h4 class="c-predefined-mutations__item-name">
{{ mutation.name }}
</h4>
<p class="c-predefined-mutations__item-description">
{{ mutation.description }}
</p>
</div>
<div class="flex items-center gap-6">
<div class="c-predefined-mutations__item-amount">
{{ mutation.amount }}
</div>
<div class="flex items-center gap-2">
<InertiaLink
:href="route('dynamics.ledgers.predefined-mutations.edit', [dynamic.id, ledger.id, mutation.uuid])"
class="c-predefined-mutations__edit-btn"
>
Edit
</InertiaLink>
<button
@click="destroy(mutation.uuid)"
class="c-predefined-mutations__delete-btn"
>
Delete
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="c-predefined-mutations__card mt-8">
<div class="c-predefined-mutations__body">
<h3 class="c-predefined-mutations__title">
Create New Predefined Mutation
</h3>
<form
@submit.prevent="submit"
class="c-predefined-mutations__form"
>
<div class="c-predefined-mutations__field">
<label
for="name"
class="c-predefined-mutations__label"
>Name</label
>
<input
v-model="form.name"
id="name"
type="text"
class="c-predefined-mutations__input"
/>
</div>
<div class="c-predefined-mutations__field">
<label
for="description"
class="c-predefined-mutations__label"
>Description</label
>
<textarea
v-model="form.description"
id="description"
rows="4"
class="c-predefined-mutations__textarea"
></textarea>
</div>
<div class="c-predefined-mutations__field">
<label
for="amount"
class="c-predefined-mutations__label"
>Amount</label
>
<input
v-model="form.amount"
id="amount"
type="number"
class="c-predefined-mutations__input"
/>
</div>
<div class="c-predefined-mutations__actions">
<button
type="submit"
:disabled="form.processing"
class="c-predefined-mutations__submit-btn"
>
Create
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../../css/app.css";
.c-predefined-mutations {
@apply py-12;
}
.c-predefined-mutations__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-predefined-mutations__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-predefined-mutations__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-predefined-mutations__title {
@apply text-lg font-medium;
}
.c-predefined-mutations__list {
@apply mt-6 space-y-4;
}
.c-predefined-mutations__item {
@apply flex items-center justify-between rounded-lg border p-4 dark:border-gray-700;
}
.c-predefined-mutations__item-details {
@apply flex-1;
}
.c-predefined-mutations__item-name {
@apply font-semibold;
}
.c-predefined-mutations__item-description {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.c-predefined-mutations__item-amount {
@apply text-lg font-semibold;
}
.c-predefined-mutations__edit-btn {
@apply inline-flex cursor-pointer items-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-semibold text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600;
}
.c-predefined-mutations__delete-btn {
@apply inline-flex cursor-pointer items-center rounded border border-transparent bg-red-600 px-2.5 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2;
}
.c-predefined-mutations__form {
@apply mt-6 space-y-6;
}
.c-predefined-mutations__field {
@apply block;
}
.c-predefined-mutations__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-predefined-mutations__input {
@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-predefined-mutations__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-predefined-mutations__actions {
@apply flex items-center gap-4;
}
.c-predefined-mutations__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>

View File

@ -1,31 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link as InertiaLink } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { useEcho } from '@laravel/echo-vue'; import { useEcho } from '@laravel/echo-vue';
import { ref } from 'vue'; import { ref } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import AddMutationForm from '@/components/AddMutationForm.vue'; import AddMutationForm from '@/components/AddMutationForm.vue';
import Chat from '@/components/Chat.vue';
import MutationList from '@/components/MutationList.vue'; import MutationList from '@/components/MutationList.vue';
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{
title: 'Dynamics',
href: route('dynamics.index'),
},
{
title: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
title: props.ledger.name,
href: route('dynamics.ledgers.show', [props.dynamic.id, props.ledger.id]),
},
],
}),
});
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
id: number; id: number;
@ -43,14 +23,7 @@ const props = defineProps<{
score: number; score: number;
rules: string; rules: string;
alignment: string; alignment: string;
status: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
predefined_mutations?: Array<{
id: string;
name: string;
description: string | null;
amount: number;
}>;
mutations: Array<{ mutations: Array<{
id: number; id: number;
user_id: number; user_id: number;
@ -63,15 +36,26 @@ const props = defineProps<{
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
can: { isOwner: boolean;
update: boolean; }>();
close: boolean;
}; const breadcrumbs = [
messages: { {
data: Array<any>; name: 'Dynamics',
next_page_url: string | null; href: route('dynamics.index'),
}; },
}>(); {
name: props.dynamic.name,
href: route('dynamics.show', props.dynamic.id),
},
{
name: props.ledger.name,
href: route('dynamics.ledgers.show', {
dynamic: props.dynamic.id,
ledger: props.ledger.id,
}),
},
];
// Lightbox Modal state // Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null); const activeLightboxUrl = ref<string | null>(null);
@ -184,31 +168,13 @@ function isOwnerUser(userId: number): boolean {
<div class="c-ledger-show__container"> <div class="c-ledger-show__container">
<div class="c-ledger-show__card"> <div class="c-ledger-show__card">
<div class="c-ledger-show__body"> <div class="c-ledger-show__body">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-4"> <h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
<div> <p class="c-ledger-show__score">
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3> Score: {{ ledger.score }}
<p class="c-ledger-show__score"> </p>
Score: {{ ledger.score }} <p class="c-ledger-show__rules">
</p> {{ ledger.rules }}
<p class="c-ledger-show__rules"> </p>
{{ ledger.rules }}
</p>
</div>
<div v-if="can.update" class="flex flex-col gap-2">
<InertiaLink
:href="route('dynamics.ledgers.predefined-mutations.index', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Predefined Mutations
</InertiaLink>
<InertiaLink
:href="route('dynamics.ledgers.edit', [dynamic.id, ledger.id])"
class="c-ledger-show__manage-btn"
>
Edit Ledger
</InertiaLink>
</div>
</div>
<!-- Ledger Alignment Badge / Subtitle --> <!-- Ledger Alignment Badge / Subtitle -->
<div class="c-ledger-show__alignment-wrapper"> <div class="c-ledger-show__alignment-wrapper">
@ -268,7 +234,7 @@ function isOwnerUser(userId: number): boolean {
</div> </div>
<!-- Add Mutation Form Component --> <!-- Add Mutation Form Component -->
<AddMutationForm :dynamic-id="dynamic.id" :ledger-id="ledger.id" :predefined-mutations="ledger.predefined_mutations" /> <AddMutationForm :dynamic-id="dynamic.id" :ledger-id="ledger.id" />
<!-- Mutation List Component --> <!-- Mutation List Component -->
<MutationList <MutationList
@ -276,12 +242,10 @@ function isOwnerUser(userId: number): boolean {
:ledger-id="ledger.id" :ledger-id="ledger.id"
:mutations="ledger.mutations" :mutations="ledger.mutations"
:participants="dynamic.participants" :participants="dynamic.participants"
:can-update="can.update" :is-owner="isOwner"
:ledger-alignment="ledger.alignment" :ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox" @open-lightbox="openLightbox"
/> />
<Chat :chat="dynamic.chat" :initial-messages="messages" :participants="dynamic.participants" :dynamic-id="dynamic.id" :ledger-id="ledger.id" />
</div> </div>
</div> </div>
@ -324,10 +288,6 @@ function isOwnerUser(userId: number): boolean {
@apply py-12; @apply py-12;
} }
.c-ledger-show__manage-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;
}
.c-ledger-show__container { .c-ledger-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8; @apply mx-auto max-w-7xl sm:px-6 lg:px-8;
} }

View File

@ -1,308 +0,0 @@
<script setup lang="ts">
import Chat from '@/components/Chat.vue';
import { defineOptions } from 'vue';
import { useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
mutation: {
id: string; // Updated to match serialization
user_id: number;
user: { name: string };
amount: number;
description: string;
status: string;
created_at: string;
chat: any;
media?: Array<{ id: number; url: string; mime_type: string }>;
can: {
update: boolean;
void: boolean;
};
ledger: {
id: string;
name: string;
dynamic_id: string;
alignment?: string;
dynamic: {
id: string;
name: string;
}
}
};
dynamic: {
id: string;
participants: any[];
};
can: {
update: boolean;
};
}>();
const dynamicId = props.dynamic.id;
function updateStatus(mutationId: string, status: 'approved' | 'rejected') {
useForm({ status }).put(
route('dynamics.ledgers.mutations.update', {
dynamic: props.dynamic.id,
ledger: props.mutation.ledger.id,
mutation: mutationId,
}),
);
}
function voidMutation(mutationId: string) {
useForm({}).put(
route('dynamics.ledgers.mutations.void', {
dynamic: props.dynamic.id,
ledger: props.mutation.ledger.id,
mutation: mutationId,
}),
);
}
function isOwnerUser(userId: number): boolean {
const participant = props.dynamic.participants?.find((p) => p.id === userId);
return participant?.pivot?.role === 'owner';
}
function getAmountClass(amount: number): string {
const alignment = props.mutation.ledger.alignment || 'neutral';
if (alignment === 'positive') {
return amount > 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
}
if (alignment === 'negative') {
return amount < 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
}
return 'c-mutation-list__item-amount--neutral';
}
defineOptions({
layout: (props: any) => ({
breadcrumbs: [
{ title: 'Dynamics', href: route('dynamics.index') },
{ title: props.mutation.ledger.dynamic.name, href: route('dynamics.show', props.mutation.ledger.dynamic.id) },
{ title: props.mutation.ledger.name, href: route('dynamics.ledgers.show', [props.mutation.ledger.dynamic.id, props.mutation.ledger.id]) },
{ title: 'Mutation Details', href: '' },
]
})
});
</script>
<template>
<div class="c-mutation-list__item">
<div class="c-mutation-list__item-header">
<div>
<span class="c-mutation-list__item-author">
{{
isOwnerUser(mutation.user_id)
? 'Added by'
: 'Suggested by'
}}
{{ mutation.user.name }}
</span>
<div class="c-mutation-list__item-meta">
<span
:class="getAmountClass(mutation.amount)"
class="c-mutation-list__item-amount"
>
{{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }}
</span>
<span
v-if="!isOwnerUser(mutation.user_id)"
:class="{
'c-mutation-list__item-status--pending':
mutation.status === 'pending',
'c-mutation-list__item-status--approved':
mutation.status === 'approved',
'c-mutation-list__item-status--rejected':
mutation.status === 'rejected',
}"
class="c-mutation-list__item-status"
>
{{ mutation.status }}
</span>
</div>
</div>
<div class="c-mutation-list__item-time">
{{ new Date(mutation.created_at).toLocaleString() }}
</div>
</div>
<p class="c-mutation-list__item-desc">
{{ mutation.description }}
</p>
<div
v-if="mutation.media && mutation.media.length > 0"
class="c-mutation-list__media-list"
>
<div
v-for="item in mutation.media"
:key="item.id"
class="c-mutation-list__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
class="c-mutation-list__media-img"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="c-mutation-list__media-video-wrapper"
>
<video
:src="item.url"
class="c-mutation-list__media-video"
></video>
<div class="c-mutation-list__media-video-overlay">
</div>
</div>
</div>
</div>
<!-- Owner Approve/Reject/Void Actions -->
<div
v-if="mutation.can?.update || mutation.can?.void"
class="c-mutation-list__actions"
>
<button
v-if="mutation.can?.update"
@click="updateStatus(mutation.id, 'approved')"
class="c-mutation-list__approve-btn"
>
Approve
</button>
<button
v-if="mutation.can?.update"
@click="updateStatus(mutation.id, 'rejected')"
class="c-mutation-list__reject-btn"
>
Reject
</button>
<button
v-if="mutation.can?.void"
@click="voidMutation(mutation.id)"
class="c-mutation-list__void-btn"
>
Void
</button>
</div>
<Chat v-if="mutation.chat"
:chat="mutation.chat"
:participants="dynamic.participants"
:dynamic-id="dynamicId"
/>
</div>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-mutation-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-mutation-list__item-header {
@apply flex items-start justify-between;
}
.c-mutation-list__item-author {
@apply font-semibold;
}
.c-mutation-list__item-meta {
@apply mt-1 flex items-center gap-2;
}
.c-mutation-list__item-amount {
@apply text-sm font-bold;
}
.c-mutation-list__item-amount--positive {
@apply text-green-500;
}
.c-mutation-list__item-amount--negative {
@apply text-red-500;
}
.c-mutation-list__item-amount--neutral {
@apply text-gray-500 dark:text-gray-400;
}
.c-mutation-list__item-status {
@apply rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase;
}
.c-mutation-list__item-status--pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.c-mutation-list__item-status--approved {
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
}
.c-mutation-list__item-status--rejected {
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
}
.c-mutation-list__item-time {
@apply text-xs text-gray-500;
}
.c-mutation-list__item-desc {
@apply mt-3 text-sm text-gray-600 dark:text-gray-400;
}
.c-mutation-list__media-list {
@apply mt-3 flex flex-wrap gap-2;
}
.c-mutation-list__media-item {
@apply max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-mutation-list__media-img {
@apply h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video {
@apply h-auto max-h-[150px] w-full;
}
.c-mutation-list__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-mutation-list__actions {
@apply mt-3 mb-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700;
}
.c-mutation-list__approve-btn {
@apply inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500;
}
.c-mutation-list__reject-btn {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
}
.c-mutation-list__void-btn {
@apply inline-flex cursor-pointer items-center rounded bg-gray-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-gray-500;
}
</style>

View File

@ -6,7 +6,6 @@ import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileCo
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
import DynamicDisplayNameItem from '@/components/DynamicDisplayNameItem.vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -24,18 +23,6 @@ defineOptions({
}, },
}); });
defineProps<{
mustVerifyEmail: boolean;
status: string | null;
dynamics: Array<{
id: number;
name: string;
pivot: {
display_name: string | null;
};
}>;
}>();
const page = usePage(); const page = usePage();
const user = computed(() => page.props.auth.user); const user = computed(() => page.props.auth.user);
</script> </script>
@ -112,27 +99,6 @@ const user = computed(() => page.props.auth.user);
> >
</div> </div>
</Form> </Form>
<!-- Dynamic-Specific Display Names Section -->
<div class="pt-8 border-t">
<Heading
variant="small"
title="Dynamic Display Names"
description="Customize your display name for each of your dynamics"
/>
<div class="mt-6 space-y-4">
<div v-if="dynamics && dynamics.length > 0" class="space-y-4">
<DynamicDisplayNameItem
v-for="dyn in dynamics"
:key="dyn.id"
:dynamic="dyn"
/>
</div>
<p v-else class="text-sm text-muted-foreground">
You are not a participant in any dynamics yet.
</p>
</div>
</div>
</div> </div>
<DeleteUser /> <DeleteUser />

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