Author SHA1 Message Date
Daan Meijer 6a92334125 docs: Document ActivityService and system message architecture in AGENTS.md and DECISIONS.md 2026-06-17 01:17:27 +02:00
Daan Meijer 93e8f035e6 refactor: Use ActivityService to create system messages in seeder 2026-06-17 01:13:45 +02:00
Daan Meijer 3c414e1ef1 refactoring of system messages
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:57:43 +02:00
Daan Meijer b9d2988e8c feat: Auto-close mobile navigation sidebar upon successful user navigation
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-17 00:43:26 +02:00
Daan Meijer 69762667bc style: Center non-chat system activity messages in chat streams
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:40:12 +02:00
Daan Meijer f493be6512 dynamiccontroller AuthorizesRequests
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m3s
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:37:18 +02:00
Daan Meijer eeb3bf85a9 no applayout
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-06-17 00:36:15 +02:00
Daan Meijer ef00a73daa inertialinks
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
linter / quality (push) Has been cancelled
2026-06-17 00:34:36 +02:00
Daan Meijer ae6a4304f9 feat: Restrict ledger creation exclusively to dynamic owners and fix route matching conflicts
linter / quality (push) Failing after 1m0s
tests / ci (8.3) (push) Failing after 47s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-17 00:29:40 +02:00
Daan Meijer d01091d5a6 split extra screens from dynamic single
linter / quality (push) Failing after 1m3s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.5) (push) Failing after 1m3s
2026-06-17 00:27:37 +02:00
Daan Meijer 8f9c4c9d1f feat: Send chat messages on Enter keypress and optimize media test environments
linter / quality (push) Failing after 1m0s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:17:01 +02:00
Daan Meijer 5b1a634d80 feat: Implement dynamic settings page
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-17 00:13:31 +02:00
Daan Meijer 379543a351 AI project docuimentation
linter / quality (push) Failing after 1m6s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.5) (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 13m41s
2026-06-16 18:13:06 +02:00
Daan Meijer 06cd53fe91 added invitations
linter / quality (push) Failing after 1m8s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m6s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-16 16:29:17 +02:00
Daan Meijer 5404b1a535 removed placeholders
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m6s
2026-06-16 15:13:37 +02:00
Daan Meijer cb01acd6e0 trust docker upstream proxies
linter / quality (push) Failing after 1m5s
tests / ci (8.4) (push) Failing after 1m8s
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.5) (push) Failing after 1m6s
2026-06-16 15:11:34 +02:00
Daan Meijer 4e9e6dce2a front-end restructuring, chat shows message ownership
linter / quality (push) Failing after 1m1s
tests / ci (8.3) (push) Failing after 49s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m4s
2026-06-16 14:13:08 +02:00
Daan Meijer d44bcf6fda formatting, browser tests 2026-06-16 10:53:44 +02:00
Daan Meijer 9a9a901d46 tests 2026-06-16 10:30:50 +02:00
75 changed files with 2569 additions and 926 deletions
+6
View File
@@ -58,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.
+33 -5
View File
@@ -19,18 +19,46 @@ This document outlines the decisions made during the development of the Ledgerrz
## Architectural & Integration Decisions ## Architectural & Integration Decisions
### 1. Style Architecture Shift to BEM ### 1. Style Architecture Shift to BEM & Modern CSS Nesting
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks. To keep the look and feel 100% intact, we used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly. To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks (or dedicated BEM component files under `/resources/css/components/`).
*Note:* Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity. * **CSS Nesting Standard (Critical Learning):** Standard CSS/PostCSS nesting (unlike SASS) **does not** support class suffix concatenation (e.g. `.block { &__element { ... } }` is invalid). All nested selectors must declare the full class name explicitly (e.g. `.block { .block__element { ... } }`), compiling to standard descendant selectors (`.block .block__element`). Suffix nesting was refactored across all 12 component stylesheets to ensure native CSS browser and LightningCSS compatibility.
* **Encapsulation:** Used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
* **Third-Party Safety:** Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
### 2. Robust Real-time Echo Fallback & Deduplication ### 2. BelongsToMany Pivot Loading caveat
When accessing pivot attributes on relationship models on the front-end (e.g. `participant.pivot.role`), the relationship definition in the Eloquent model **must** explicitly call `->withPivot('role')`. Omitting this will cause the pivot object to fail loading role attributes silently in Javascript, breaking role-based template gates. We added this to `App\Models\Dynamic::participants()`.
### 3. Robust Real-time Echo Fallback & Deduplication
To address potential initialization and bundling errors under local-development: To address potential initialization and bundling errors under local-development:
* **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles. * **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles.
* **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally). * **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally).
### 3. Controller Authorization Fix ### 4. Controller Authorization Fix
We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`. We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`.
### 5. Polymorphic Multiple-Media Attachment Support
We added a polymorphic attachments system allowing any database model to attach multiple photos and videos cleanly:
* Created `Media` polymorphic model and a migration mapping `mediable_id` and `mediable_type`.
* Attached and verified uploads across `Message` (inline chat images/videos), `Ledger` (cover documents), and `Mutation` (chore submission receipts and proof).
* Built a highly reusable `.c-lightbox` CSS component for modal previews of both image and video uploads, avoiding duplication in individual modules.
### 6. Cryptographically Secure Dynamic Invitation System
We implemented a secure Dynamic Invitation flow to allow owners to invite new members to a Dynamic under a specific role:
* **Signed temporary URLs:** Invitation links dispatched in mailable emails (`DynamicInvitationMail.php`) use Laravel's temporary signed URLs, expiring in 7 days, to prevent link tampering.
* **Intended-Email Check Gate:** To prevent link hijacking, the accept gate strictly checks that the authenticated user's email matches the exact email specified on the invitation:
```php
if ($request->user()->email !== $invitation->email) {
abort(403, 'This invitation was sent to a different email address.');
}
```
* **Access Control:** Access to invitations and creation is fully protected under Owner-only authorization checks.
### 7. Centralized Activity Service for System Messages
We created `app/Services/ActivityService.php` to centralize the creation of system messages and activities.
* **System Messages as `null` user_id**: System messages are stored as `Message` records with a `null` `user_id`, cleanly distinguishing them from user-generated content.
* **Polymorphic Subject Linking**: System messages are linked to relevant entities (e.g., a `User` who joined a dynamic, a `Ledger` that was created) via a polymorphic `subject` relationship on the `messages` table. This allows system messages on the dashboard to link directly to the relevant entity.
* **Seeder Refactoring**: The `DatabaseSeeder` was refactored to use the `ActivityService` to generate all system messages, ensuring consistency.
## 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.
+19 -7
View File
@@ -3,13 +3,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\StoreDynamicRequest; use App\Http\Requests\StoreDynamicRequest;
use App\Http\Requests\UpdateDynamicRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use App\Services\ActivityService;
class DynamicController extends Controller class DynamicController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
@@ -46,11 +46,11 @@ class DynamicController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Dynamic $dynamic, ActivityService $activityService) public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
$activityService->updateCursor(auth()->user(), $dynamic); $activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([ $dynamic->load([
'ledgers.media', 'ledgers.media',
@@ -59,8 +59,14 @@ class DynamicController extends Controller
'chat.messages.media' '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,
'isOwner' => $isOwner,
]); ]);
} }
@@ -69,15 +75,21 @@ class DynamicController extends Controller
*/ */
public function edit(Dynamic $dynamic) public function edit(Dynamic $dynamic)
{ {
// $this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Settings', [
'dynamic' => $dynamic,
]);
} }
/** /**
* Update the specified resource in storage. * Update the specified resource in storage.
*/ */
public function update(Request $request, Dynamic $dynamic) public function update(UpdateDynamicRequest $request, Dynamic $dynamic)
{ {
// $dynamic->update($request->validated());
return redirect()->route('dynamics.show', $dynamic);
} }
/** /**
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers;
use App\Mail\DynamicInvitationMail;
use App\Models\Dynamic;
use App\Models\DynamicInvitation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia;
class DynamicInvitationController extends Controller
{
use AuthorizesRequests;
/**
* Show the form for creating a new invitation.
*/
public function create(Request $request, Dynamic $dynamic)
{
// Authorize - only owners can view the invite page!
$this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Invite', [
'dynamic' => $dynamic,
]);
}
/**
* Store a newly created invitation in storage.
*/
public function store(Request $request, Dynamic $dynamic)
{
// 1. Authorize - only owners can send invitations!
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (!$isOwner) {
abort(403, 'Only dynamic owners can invite other users.');
}
// 2. Validate
$request->validate([
'email' => ['required', 'email'],
'role' => ['required', 'string', 'in:owner,participant,editor,viewer'],
]);
$email = $request->input('email');
$role = $request->input('role');
// Check if user is already a participant of this dynamic
$isParticipant = $dynamic->participants()->where('email', $email)->exists();
if ($isParticipant) {
return redirect()->back()->withErrors([
'email' => 'This user is already a participant of this dynamic.',
]);
}
// Check if there is an active pending invitation for this user
$hasPendingInvite = $dynamic->invitations()
->where('email', $email)
->where('expires_at', '>', now())
->exists();
if ($hasPendingInvite) {
return redirect()->back()->withErrors([
'email' => 'An active invitation is already pending for this email address.',
]);
}
// 3. Create Invitation
$invitation = $dynamic->invitations()->create([
'email' => $email,
'role' => $role,
'token' => Str::random(40),
'expires_at' => now()->addDays(7),
]);
// 4. Send Email
Mail::to($email)->send(new DynamicInvitationMail($invitation, $request->user()->name));
return redirect()->back()->with('success', 'Invitation successfully sent!');
}
/**
* Accept the specified invitation.
*/
public function accept(Request $request, string $token)
{
// Must be signed!
if (!$request->hasValidSignature()) {
abort(401, 'Invalid or expired signature.');
}
$invitation = DynamicInvitation::where('token', $token)->firstOrFail();
if ($invitation->isExpired()) {
abort(403, 'This invitation has expired.');
}
// Ensure the logged in user's email matches the invitation's email!
// "Only the user with the specified email address should be able to access the link."
if ($request->user()->email !== $invitation->email) {
abort(403, 'This invitation was sent to a different email address.');
}
DB::transaction(function () use ($request, $invitation) {
// Attach user to dynamic as a participant with the specified role
$dynamic = $invitation->dynamic;
$dynamic->participants()->attach($request->user()->id, ['role' => $invitation->role]);
// Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([
'user_id' => null,
'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role),
'subject_id' => $request->user()->id,
'subject_type' => \App\Models\User::class,
]);
// Delete the invitation record
$invitation->delete();
});
return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!');
}
}
+6 -2
View File
@@ -25,9 +25,13 @@ class LedgerController extends Controller
/** /**
* Show the form for creating a new resource. * Show the form for creating a new resource.
*/ */
public function create() public function create(Request $request, Dynamic $dynamic)
{ {
// $this->authorize('update', $dynamic);
return Inertia::render('Ledgers/Create', [
'dynamic' => $dynamic,
]);
} }
/** /**
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers;
use App\Models\Dynamic;
use App\Models\PredefinedMutation;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PredefinedMutationController extends Controller
{
use AuthorizesRequests;
/**
* Display a listing of the resource.
*/
public function index(Dynamic $dynamic)
{
$this->authorize('update', $dynamic);
return Inertia::render('Dynamics/PredefinedMutations/Index', [
'dynamic' => $dynamic,
'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(),
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request, Dynamic $dynamic)
{
$this->authorize('update', $dynamic);
$request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'amount' => ['required', 'integer'],
'type' => ['required', 'string', 'in:reward,penalty'],
]);
$dynamic->predefinedMutations()->create($request->all());
return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ class StoreLedgerRequest extends FormRequest
{ {
$dynamic = $this->route('dynamic'); $dynamic = $this->route('dynamic');
return $dynamic && $this->user()->can('view', $dynamic); return $dynamic && $this->user()->can('update', $dynamic);
} }
/** /**
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use App\Models\Dynamic;
class UpdateDynamicRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$dynamic = $this->route('dynamic');
return $dynamic && $this->user()->can('update', $dynamic);
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'],
];
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\DynamicInvitation;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class DynamicInvitationMail extends Mailable {
use Queueable, SerializesModels;
public function __construct(public DynamicInvitation $invitation, public string $inviterName) {
//
}
public function envelope(): Envelope {
return new Envelope(
subject: 'Invitation to Join Dynamic: ' . $this->invitation->dynamic->name,
);
}
public function content(): Content {
$acceptUrl = URL::temporarySignedRoute(
'dynamics.invitations.accept',
$this->invitation->expires_at,
['token' => $this->invitation->token]
);
return new Content(
markdown: 'emails.dynamics.invitation',
with: [
'acceptUrl' => $acceptUrl,
'dynamicName' => $this->invitation->dynamic->name,
'role' => $this->invitation->role,
],
);
}
}
+5
View File
@@ -29,6 +29,11 @@ class Dynamic extends Model
return $this->hasMany(Ledger::class); return $this->hasMany(Ledger::class);
} }
public function invitations(): HasMany
{
return $this->hasMany(DynamicInvitation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DynamicInvitation extends Model {
use HasFactory;
protected $fillable = [
'dynamic_id',
'email',
'role',
'token',
'expires_at',
];
protected $casts = [
'expires_at' => 'datetime',
];
public function dynamic(): BelongsTo {
return $this->belongsTo(Dynamic::class);
}
public function isExpired(): bool {
return $this->expires_at->isPast();
}
}
+9
View File
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model class Message extends Model
{ {
/** @use HasFactory<MessageFactory> */ /** @use HasFactory<MessageFactory> */
@@ -16,6 +18,8 @@ class Message extends Model
'chat_id', 'chat_id',
'user_id', 'user_id',
'content', 'content',
'subject_id',
'subject_type',
]; ];
public function chat(): BelongsTo public function chat(): BelongsTo
@@ -28,6 +32,11 @@ class Message extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function subject(): MorphTo
{
return $this->morphTo();
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');
+6
View File
@@ -20,6 +20,7 @@ class Mutation extends Model
'amount', 'amount',
'description', 'description',
'status', 'status',
'predefined_mutation_id',
]; ];
public function ledger(): BelongsTo public function ledger(): BelongsTo
@@ -32,6 +33,11 @@ class Mutation extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function predefinedMutation(): BelongsTo
{
return $this->belongsTo(PredefinedMutation::class);
}
public function chat(): MorphOne public function chat(): MorphOne
{ {
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PredefinedMutation extends Model
{
use HasFactory;
protected $fillable = [
'dynamic_id',
'name',
'description',
'amount',
'type',
];
public function dynamic(): BelongsTo
{
return $this->belongsTo(Dynamic::class);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ class DynamicPolicy
*/ */
public function update(User $user, Dynamic $dynamic): bool public function update(User $user, Dynamic $dynamic): bool
{ {
return false; return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
} }
/** /**
+73 -132
View File
@@ -9,6 +9,7 @@ use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Models\ReadCursor; use App\Models\ReadCursor;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ActivityService class ActivityService
{ {
@@ -44,121 +45,54 @@ 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($chat, $user, $content, $subject = null)
* Retrieve all activities for a Dynamic.
*/
public function getDynamicActivities(Dynamic $dynamic): array
{ {
$activities = []; $message = $chat->messages()->create([
'user_id' => $user ? $user->id : null,
'content' => $content,
'subject_id' => $subject ? $subject->id : null,
'subject_type' => $subject ? get_class($subject) : null,
]);
// 1. Chat Messages return $message;
if ($dynamic->chat) { }
$messages = Message::where('chat_id', $dynamic->chat->id)
->with('user')
->get();
foreach ($messages as $msg) { public function createMutation($ledger, $user, $type, $amount, $description, $status)
$activities[] = [ {
'id' => "dynamic_msg_{$msg->id}", $mutation = $ledger->mutations()->create([
'type' => 'message', 'user_id' => $user->id,
'description' => $msg->content, 'type' => $type,
'user' => [ 'amount' => $amount,
'name' => $msg->user ? $msg->user->name : 'Unknown User', 'description' => $description,
], 'status' => $status,
'created_at' => $msg->created_at, ]);
];
}
}
// 2. Ledgers Created return $mutation;
$ledgers = Ledger::where('dynamic_id', $dynamic->id)->get();
foreach ($ledgers as $ledger) {
$activities[] = [
'id' => "ledger_created_{$ledger->id}",
'type' => 'ledger_created',
'description' => "New Ledger '{$ledger->name}' was created.",
'user' => [
'name' => 'System',
],
'created_at' => $ledger->created_at,
];
}
// Sort activities chronologically ascending
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
return $activities;
} }
/** /**
* Retrieve all activities for a Ledger. * Retrieve all activities for a given entity.
*/ */
public function getLedgerActivities(Ledger $ledger): array public function getActivitiesForEntity($entity): array
{ {
$activities = []; if ($entity instanceof Dynamic) {
$chatId = $entity->chat->id;
// 1. Mutations (Creation and Status updates) } elseif ($entity instanceof Ledger) {
$mutations = Mutation::where('ledger_id', $ledger->id) $chatId = $entity->dynamic->chat->id;
->with('user') } else {
->get(); return [];
foreach ($mutations as $mutation) {
// Log creation of mutation
$verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry');
$activities[] = [
'id' => "mutation_created_{$mutation->id}",
'type' => 'mutation_created',
'description' => "{$verb} ({$mutation->amount}): \"{$mutation->description}\"",
'user' => [
'name' => $mutation->user ? $mutation->user->name : 'Unknown User',
],
'created_at' => $mutation->created_at,
];
// Log status approval/rejection update if different from creation
if ($mutation->status !== 'pending' && $mutation->updated_at->gt($mutation->created_at->addSeconds(2))) {
$activities[] = [
'id' => "mutation_updated_{$mutation->id}",
'type' => 'mutation_updated',
'description' => "Entry '{$mutation->description}' was " . strtoupper($mutation->status),
'user' => [
'name' => 'System',
],
'created_at' => $mutation->updated_at,
];
}
} }
// 2. Mutation Comments $messages = Message::where('chat_id', $chatId)
if ($mutations->isNotEmpty()) { ->with(['user', 'subject'])
$comments = Message::whereHas('chat', function ($q) use ($mutations) { ->latest()
$q->where('chatable_type', Mutation::class)
->whereIn('chatable_id', $mutations->pluck('id'));
})
->with(['user', 'chat.chatable'])
->get(); ->get();
foreach ($comments as $comment) { return $messages->map(function ($message) {
/** @var Mutation|null $mutationEntity */ $messageData = $message->toArray();
$mutationEntity = $comment->chat->chatable; $messageData['url'] = $this->getUrlForMessage($message);
$context = $mutationEntity ? " on \"{$mutationEntity->description}\"" : ""; return $messageData;
})->all();
$activities[] = [
'id' => "comment_{$comment->id}",
'type' => 'comment',
'description' => "Commented{$context}: \"{$comment->content}\"",
'user' => [
'name' => $comment->user ? $comment->user->name : 'Unknown User',
],
'created_at' => $comment->created_at,
];
}
}
// Sort activities chronologically ascending
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
return $activities;
} }
/** /**
@@ -167,27 +101,15 @@ class ActivityService
public function getUnreadEntitiesGrouped(User $user): array public function getUnreadEntitiesGrouped(User $user): array
{ {
$groupedEntities = []; $groupedEntities = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
// 1. Get all participating Dynamics $entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
$dynamics = $user->dynamics()->get();
foreach ($dynamics as $dynamic) { foreach ($entities as $entity) {
$readAt = $this->getCursorReadAt($user, $dynamic); $readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getDynamicActivities($dynamic); $activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities); $this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
}
// 2. Get all Ledgers under those Dynamics
if ($dynamics->isNotEmpty()) {
$ledgers = Ledger::whereIn('dynamic_id', $dynamics->pluck('id'))->get();
foreach ($ledgers as $ledger) {
$readAt = $this->getCursorReadAt($user, $ledger);
$activities = $this->getLedgerActivities($ledger);
$this->partitionActivities($activities, $readAt, $ledger, 'Ledger', route('dynamics.ledgers.show', [$ledger->dynamic_id, $ledger->id]), $groupedEntities);
}
} }
return $groupedEntities; return $groupedEntities;
@@ -202,7 +124,7 @@ class ActivityService
$unread = []; $unread = [];
foreach ($activities as $act) { foreach ($activities as $act) {
if ($act['created_at']->gt($readAt)) { if (Carbon::parse($act['created_at'])->gt($readAt)) {
$unread[] = $act; $unread[] = $act;
} else { } else {
$alreadyRead[] = $act; $alreadyRead[] = $act;
@@ -210,24 +132,43 @@ class ActivityService
} }
if (!empty($unread)) { if (!empty($unread)) {
// We have unread activity! Let's pull the last two read items as context $context = array_slice($alreadyRead, 0, 2);
$context = array_slice($alreadyRead, -2);
// Format timestamps for serializing to frontend
$formatActivity = function ($act) {
$act['created_at'] = $act['created_at']->toIso8601String();
return $act;
};
$groupedEntities[] = [ $groupedEntities[] = [
'id' => $entity->id, 'id' => $entity->id,
'name' => $entity->name, 'name' => $entity->name,
'type' => $type, 'type' => Str::afterLast($type, '\\'),
'url' => $url, 'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => array_map($formatActivity, $context), 'context_activities' => $context,
'new_activities' => array_map($formatActivity, $unread), 'new_activities' => $unread,
]; ];
} }
} }
private function getUrlForEntity($entity): string
{
if ($entity instanceof Dynamic) {
return route('dynamics.show', $entity->id);
}
if ($entity instanceof Ledger) {
return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]);
}
return '';
}
private function getUrlForMessage(Message $message): string
{
if ($message->subject) {
return $this->getUrlForEntity($message->subject);
}
if ($message->chat->chatable) {
return $this->getUrlForEntity($message->chat->chatable);
}
return '';
}
} }
+14 -9
View File
@@ -10,22 +10,27 @@ 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 {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware
HandleAppearance::class, ->web(append: [
HandleInertiaRequests::class, HandleAppearance::class,
AddLinkHeadersForPreloadedAssets::class, HandleInertiaRequests::class,
]); AddLinkHeadersForPreloadedAssets::class,
])
->trustProxies(at: [
'172.16.0.0/12',
]);
}) })
->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();
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('dynamic_invitations', function (Blueprint $table) {
$table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
$table->string('email');
$table->string('role');
$table->string('token')->unique();
$table->timestamp('expires_at');
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('dynamic_invitations');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->foreignId('user_id')->nullable()->change();
$table->nullableMorphs('subject');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->foreignId('user_id')->nullable(false)->change();
$table->dropMorphs('subject');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('predefined_mutations', function (Blueprint $table) {
$table->id();
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->text('description')->nullable();
$table->integer('amount');
$table->string('type')->default('reward');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('predefined_mutations');
}
};
@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->foreignId('predefined_mutation_id')->nullable()->constrained()->onDelete('set null');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('mutations', function (Blueprint $table) {
$table->dropForeign(['predefined_mutation_id']);
$table->dropColumn('predefined_mutation_id');
});
}
};
+34 -199
View File
@@ -7,6 +7,7 @@ use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -16,9 +17,9 @@ 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,7 +53,9 @@ 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)
@@ -64,29 +67,10 @@ class DatabaseSeeder extends Seeder
$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,145 +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' => $alice->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' => $alice->id,
'type' => 'penalty',
'amount' => 10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $alice->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']);
@@ -261,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([
@@ -303,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',
]);
}); });
} }
} }
+2
View File
@@ -2,6 +2,8 @@
@import 'tw-animate-css'; @import 'tw-animate-css';
@import './components.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
+17
View File
@@ -0,0 +1,17 @@
/* ==========================================================================
BEM UI Components Index Manifest (Tailwind CSS v4)
========================================================================== */
@import './components/heading.css';
@import './components/input-error.css';
@import './components/text-link.css';
@import './components/app-logo.css';
@import './components/user-info.css';
@import './components/sidebar-header.css';
@import './components/app-content.css';
@import './components/appearance-tabs.css';
@import './components/password-input.css';
@import './components/auth-layout.css';
@import './components/chat.css';
@import './components/lightbox.css';
@import './components/invite-form.css';
+5
View File
@@ -0,0 +1,5 @@
/* 7. AppContent Layout Component */
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4;
border-radius: var(--radius);
}
+20
View File
@@ -0,0 +1,20 @@
/* 4. AppLogo Component */
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
color: var(--sidebar-primary-foreground);
.app-logo__icon {
@apply size-5 fill-current;
}
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
color: var(--foreground);
}
}
@@ -0,0 +1,35 @@
/* 8. AppearanceTabs Component */
.appearance-tabs {
@apply inline-flex gap-1 p-1;
border-radius: var(--radius);
background-color: var(--muted);
.appearance-tabs__tab {
@apply flex cursor-pointer items-center px-3.5 py-1.5 transition-colors;
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
background-color: rgba(0, 0, 0, 0.05);
}
&.appearance-tabs__tab--active {
background-color: var(--card);
color: var(--card-foreground);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
&:hover {
background-color: var(--card);
}
}
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
}
+46
View File
@@ -0,0 +1,46 @@
/* 10. AuthSimpleLayout Component */
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10;
background-color: var(--background);
.auth-layout__container {
@apply w-full max-w-sm;
.auth-layout__inner {
@apply flex flex-col gap-8;
.auth-layout__header {
@apply flex flex-col items-center gap-4;
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center;
border-radius: var(--radius);
background-color: var(--sidebar-primary);
.auth-layout__logo {
@apply size-9 fill-current;
color: var(--sidebar-primary-foreground);
}
}
}
.auth-layout__title-box {
@apply space-y-2 text-center;
.auth-layout__title {
@apply text-xl font-medium;
color: var(--foreground);
}
.auth-layout__description {
@apply text-center text-sm;
color: var(--muted-foreground);
}
}
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
/* 11. Chat Component */
.c-chat {
@apply mt-8;
.c-chat__title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-chat__list {
@apply mt-4 flex flex-col gap-3;
.c-chat__message {
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
border: 1px solid var(--border);
&.c-chat__message--system {
@apply w-full self-center border-0 bg-transparent p-0 shadow-none;
}
&.c-chat__message--own {
@apply self-end rounded-br-none text-right;
max-width: 80%;
background-color: var(--primary);
border-color: var(--primary);
.c-chat__message-header {
@apply flex-row-reverse;
}
.c-chat__message-author {
color: var(--primary-foreground);
}
.c-chat__message-time {
color: var(--primary-foreground);
opacity: 0.7;
}
.c-chat__message-text {
color: var(--primary-foreground);
}
}
&.c-chat__message--other {
@apply self-start rounded-bl-none;
max-width: 80%;
background-color: var(--muted);
border-color: var(--border);
.c-chat__message-author {
color: var(--foreground);
}
.c-chat__message-time {
color: var(--muted-foreground);
}
.c-chat__message-text {
color: var(--foreground);
}
}
.c-chat__system-inner {
@apply mx-auto flex w-fit items-center justify-center gap-2 px-3 py-1.5 text-xs;
background-color: var(--muted);
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 2px);
color: var(--muted-foreground);
.c-chat__system-icon {
@apply size-3.5 shrink-0;
color: var(--muted-foreground);
}
.c-chat__system-text {
@apply font-medium;
}
.c-chat__system-time {
@apply shrink-0 text-[10px];
color: var(--muted-foreground);
}
}
.c-chat__message-header {
@apply flex justify-between;
.c-chat__message-author {
@apply font-semibold;
color: var(--foreground);
}
.c-chat__message-time {
@apply text-xs;
color: var(--muted-foreground);
}
}
.c-chat__message-text {
@apply mt-2 text-sm;
color: var(--foreground);
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden bg-black;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
}
}
}
}
.c-chat__empty {
color: var(--muted-foreground);
}
.c-chat__form {
@apply mt-6 space-y-6;
.c-chat__form-group {
@apply space-y-2;
.c-chat__label {
@apply block text-sm font-medium;
color: var(--foreground);
}
.c-chat__textarea {
@apply mt-1 block w-full shadow-sm;
border-radius: var(--radius);
border: 1px solid var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-chat__error {
@apply text-sm;
color: var(--destructive);
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs transition-colors;
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
}
.c-chat__attach-icon {
@apply size-3.5;
}
}
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 p-1.5 pr-8 text-xs;
border-radius: calc(var(--radius) - 2px);
border: 1px solid var(--border);
background-color: var(--muted);
.c-chat__preview-name {
@apply max-w-[150px] truncate;
color: var(--foreground);
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px];
color: var(--muted-foreground);
&:hover {
color: var(--destructive);
}
}
}
}
}
.c-chat__submit-box {
@apply flex items-center gap-4;
.c-chat__button {
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
border-radius: var(--radius);
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
/* 1. Heading Component */
.c-heading {
@apply mb-8 space-y-0.5;
&.c-heading--small {
@apply mb-0 space-y-0;
.c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
color: var(--foreground);
}
.c-heading__description {
@apply text-sm;
color: var(--muted-foreground);
}
}
+5
View File
@@ -0,0 +1,5 @@
/* 2. InputError Component */
.c-input-error__message {
@apply text-sm;
color: var(--destructive);
}
+67
View File
@@ -0,0 +1,67 @@
/* 13. InviteForm Component */
.c-invite-form {
@apply mt-8;
.c-invite-form__card {
@apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
.c-invite-form__body {
@apply p-6;
color: var(--foreground);
.c-invite-form__title {
@apply text-lg font-medium;
}
.c-invite-form__form {
@apply mt-6 space-y-6;
.c-invite-form__field {
@apply block;
.c-invite-form__label {
@apply block text-sm font-medium;
color: var(--foreground);
}
.c-invite-form__input {
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-invite-form__select {
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
}
.c-invite-form__error {
@apply text-sm;
color: var(--destructive);
}
}
.c-invite-form__actions {
@apply flex items-center gap-4;
.c-invite-form__submit-btn {
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
border-radius: var(--radius);
background-color: var(--primary);
color: var(--primary-foreground);
&:hover {
opacity: 0.9;
}
}
}
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
/* 12. Reusable Lightbox Component */
.c-lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
background-color: rgba(0, 0, 0, 0.95);
.c-lightbox__close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200;
&:hover {
color: var(--destructive);
}
}
.c-lightbox__content {
@apply max-h-full max-w-full;
.c-lightbox__image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-lightbox__video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
}
}
@@ -0,0 +1,21 @@
/* 9. PasswordInput Component */
.password-input {
@apply relative;
.password-input__field {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none;
color: var(--muted-foreground);
&:hover {
color: var(--foreground);
}
}
.password-input__icon {
@apply size-4;
}
}
@@ -0,0 +1,14 @@
/* 6. AppSidebarHeader Component */
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear md:px-4;
border-bottom: 1px solid var(--sidebar-border);
background-color: var(--sidebar-background);
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
.group-has-data-[collapsible='icon']/sidebar-wrapper:h-12 .sidebar-header {
@apply h-12;
}
+10
View File
@@ -0,0 +1,10 @@
/* 3. TextLink Component */
.c-text-link {
@apply underline underline-offset-4 transition-colors duration-300 ease-out;
color: var(--foreground);
text-decoration-color: var(--border);
&:hover {
text-decoration-color: var(--foreground);
}
}
+19
View File
@@ -0,0 +1,19 @@
/* 5. UserInfo Component */
.user-info__avatar {
@apply h-8 w-8 overflow-hidden;
border-radius: var(--radius);
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
.user-info__name {
@apply truncate font-medium;
color: var(--foreground);
}
.user-info__email {
@apply truncate text-xs;
color: var(--muted-foreground);
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ configureEcho({
forceTLS: false, forceTLS: false,
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
if(window){ if (typeof window !== 'undefined') {
(window as any).echoConfigured = true; (window as any).echoConfigured = true;
} }
+9 -16
View File
@@ -42,17 +42,10 @@ function submit() {
<template> <template>
<div class="c-add-mutation-form"> <div class="c-add-mutation-form">
<h4 class="c-add-mutation-form__title"> <h4 class="c-add-mutation-form__title">Add Mutation</h4>
Add Mutation <form @submit.prevent="submit" class="c-add-mutation-form__form">
</h4>
<form
@submit.prevent="submit"
class="c-add-mutation-form__form"
>
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label for="amount" class="c-add-mutation-form__label"
for="amount"
class="c-add-mutation-form__label"
>Amount</label >Amount</label
> >
<input <input
@@ -61,15 +54,16 @@ function submit() {
type="number" type="number"
class="c-add-mutation-form__input" class="c-add-mutation-form__input"
/> />
<div v-if="form.errors.amount" class="c-add-mutation-form__error"> <div
v-if="form.errors.amount"
class="c-add-mutation-form__error"
>
{{ form.errors.amount }} {{ form.errors.amount }}
</div> </div>
</div> </div>
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label for="description" class="c-add-mutation-form__label"
for="description"
class="c-add-mutation-form__label"
>Description</label >Description</label
> >
<textarea <textarea
@@ -88,8 +82,7 @@ function submit() {
<!-- Media Uploads for Mutations --> <!-- Media Uploads for Mutations -->
<div class="c-add-mutation-form__field"> <div class="c-add-mutation-form__field">
<label <label class="c-add-mutation-form__label"
class="c-add-mutation-form__label"
>Attach Proof Media (Photos/Videos)</label >Attach Proof Media (Photos/Videos)</label
> >
<input <input
-8
View File
@@ -22,11 +22,3 @@ const className = computed(() => props.class);
<slot /> <slot />
</main> </main>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
}
</style>
+5 -2
View File
@@ -33,6 +33,7 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { route } from 'ziggy-js';
import { getInitials } from '@/composables/useInitials'; import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
@@ -239,7 +240,7 @@ const rightNavItems: NavItem[] = [
</div> </div>
<div <div
v-if="page.props.unreadNotificationsCount > 0" v-if="(page.props as any).unreadNotificationsCount > 0"
class="relative" class="relative"
> >
<Link <Link
@@ -260,7 +261,9 @@ const rightNavItems: NavItem[] = [
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/> />
</svg> </svg>
<span class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"> <span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
>
{{ page.props.unreadNotificationsCount }} {{ page.props.unreadNotificationsCount }}
</span> </span>
</Link> </Link>
+1 -21
View File
@@ -7,26 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="app-logo__icon" /> <AppLogoIcon class="app-logo__icon" />
</div> </div>
<div class="app-logo__text-container"> <div class="app-logo__text-container">
<span class="app-logo__text">Laravel Starter Kit</span> <span class="app-logo__text">Ledgerrz</span>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
}
.app-logo__icon {
@apply size-5 fill-current text-white dark:text-black;
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
}
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
}
</style>
+31 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue'; import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
import { onMounted, onUnmounted } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue'; import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
@@ -13,11 +14,30 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as dynamics } from '@/routes/dynamics'; import { index as dynamics } from '@/routes/dynamics';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const { isMobile, setOpenMobile } = useSidebar();
let unregister: () => void;
onMounted(() => {
unregister = router.on('navigate', () => {
if (isMobile.value) {
setOpenMobile(false);
}
});
});
onUnmounted(() => {
if (unregister) {
unregister();
}
});
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
]; ];
const footerNavItems: NavItem[] = [ const footerNavItems: NavItem[] = [
{ // {
title: 'Repository', // title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit', // href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2, // icon: FolderGit2,
}, // },
{ // {
title: 'Documentation', // title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue', // href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen, // icon: BookOpen,
}, // },
]; ];
</script> </script>
@@ -23,15 +23,3 @@ withDefaults(
</div> </div>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
</style>
@@ -27,27 +27,3 @@ const tabs = [
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.appearance-tabs {
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
}
.appearance-tabs__tab {
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
}
.appearance-tabs__tab--active {
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
</style>
+83 -190
View File
@@ -1,16 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } 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 { Paperclip } from '@lucide/vue';
import { ref } from 'vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue';
const props = defineProps<{ const props = defineProps<{
chat: { chat: {
id: number; id: number;
messages: Array<{ messages: Array<{
id: number; id: number;
user: { name: string }; user: { id: number; name: string };
content: string; content: string;
created_at: string; created_at: string;
media?: Array<{ media?: Array<{
@@ -52,7 +52,6 @@ useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
function handleFileChange(event: Event) { function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
if (files) { if (files) {
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
form.media.push(files[i]); form.media.push(files[i]);
@@ -64,11 +63,16 @@ function removeFile(index: number) {
form.media.splice(index, 1); form.media.splice(index, 1);
} }
const currentUser = computed(() => usePage().props.auth?.user);
function isOwnMessage(messageUserId: number): boolean {
return currentUser.value && currentUser.value.id === messageUserId;
}
function submit() { function submit() {
form.post(route('chats.messages.store', props.chat.id), { form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => { onSuccess: () => {
form.reset(); form.reset();
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = ''; fileInput.value.value = '';
} }
@@ -100,50 +104,81 @@ function closeLightbox() {
<div <div
v-for="message in chat.messages" v-for="message in chat.messages"
:key="message.id" :key="message.id"
class="c-chat__message" :class="[
'c-chat__message',
{
'c-chat__message--system':
message.user.id === 0,
'c-chat__message--own': isOwnMessage(message.user.id),
'c-chat__message--other': !isOwnMessage(
message.user.id,
),
},
]"
> >
<div class="c-chat__message-header"> <!-- Standard User Chat Message -->
<span class="c-chat__message-author">{{ <template v-if="!message.content.startsWith('System:')">
message.user.name <div class="c-chat__message-header">
}}</span> <span class="c-chat__message-author">{{
<span class="c-chat__message-time">{{ message.user.name
new Date(message.created_at).toLocaleString() }}</span>
}}</span> <span class="c-chat__message-time">{{
</div> new Date(message.created_at).toLocaleString()
<p class="c-chat__message-text"> }}</span>
{{ message.content }} </div>
</p> <p class="c-chat__message-text">
{{ message.content }}
</p>
<!-- Attached Media Display --> <!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div <div
v-for="item in message.media" v-if="message.media && message.media.length > 0"
:key="item.id" class="c-chat__message-media"
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 <div
v-else-if="item.mime_type.startsWith('video/')" v-for="item in message.media"
class="relative cursor-pointer transition-opacity hover:opacity-90" :key="item.id"
@click="openLightbox(item.url, item.mime_type)" class="c-chat__media-item"
> >
<video <img
v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="c-chat__video" :alt="item.file_name"
></video> class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
<div class="c-chat__play-overlay"></div> @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>
</div> </div>
</div> </template>
<!-- Subtle Activity Log System Message -->
<template v-else>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span class="c-chat__system-text">
{{ 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="chat.messages.length === 0" class="c-chat__empty"> <div v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
@@ -157,7 +192,8 @@ function closeLightbox() {
id="content" id="content"
rows="3" rows="3"
class="c-chat__textarea" class="c-chat__textarea"
placeholder="Type a message..." placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
@keydown.enter.exact.prevent="submit"
></textarea> ></textarea>
<div v-if="form.errors.content" class="c-chat__error"> <div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }} {{ form.errors.content }}
@@ -215,165 +251,22 @@ function closeLightbox() {
</form> </form>
<!-- Gorgeous Dark Lightbox Modal --> <!-- Gorgeous Dark Lightbox Modal -->
<div <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
v-if="activeLightboxUrl" <button @click="closeLightbox" class="c-lightbox__close"></button>
class="c-chat__lightbox" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button @click="closeLightbox" class="c-chat__lightbox-close">
</button>
<div class="c-chat__lightbox-content" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-chat__lightbox-image" class="c-lightbox__image"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-chat__lightbox-video" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply mt-8;
}
.c-chat__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-chat__list {
@apply mt-4 space-y-4;
}
.c-chat__message {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-chat__message-header {
@apply flex justify-between;
}
.c-chat__message-author {
@apply font-semibold;
}
.c-chat__message-time {
@apply text-xs text-gray-500;
}
.c-chat__message-text {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-chat__empty {
@apply text-gray-500;
}
.c-chat__form {
@apply mt-6 space-y-6;
}
.c-chat__form-group {
@apply space-y-2;
}
.c-chat__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-chat__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-chat__error {
@apply text-sm text-red-600;
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
}
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs text-neutral-500 transition-colors hover:text-foreground;
}
.c-chat__attach-icon {
@apply size-3.5;
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate;
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-chat__submit-box {
@apply flex items-center gap-4;
}
.c-chat__button {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
/* Lightbox Styling */
.c-chat__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-chat__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-chat__lightbox-content {
@apply max-h-full max-w-full;
}
.c-chat__lightbox-image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-chat__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style>
+40 -21
View File
@@ -40,11 +40,12 @@ function submit() {
<div class="c-create-ledger-form__body"> <div class="c-create-ledger-form__body">
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3> <h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
<form @submit.prevent="submit" class="c-create-ledger-form__form"> <form
@submit.prevent="submit"
class="c-create-ledger-form__form"
>
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label for="name" class="c-create-ledger-form__label"
for="name"
class="c-create-ledger-form__label"
>Name</label >Name</label
> >
<input <input
@@ -62,9 +63,7 @@ function submit() {
</div> </div>
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label for="rules" class="c-create-ledger-form__label"
for="rules"
class="c-create-ledger-form__label"
>Rules</label >Rules</label
> >
<textarea <textarea
@@ -92,9 +91,15 @@ function submit() {
id="alignment" id="alignment"
class="c-create-ledger-form__select" class="c-create-ledger-form__select"
> >
<option value="positive">Positive (Higher Score is Better)</option> <option value="positive">
<option value="neutral">Neutral (Frictionless / Standard)</option> Positive (Higher Score is Better)
<option value="negative">Negative (Lower Score is Better / Demerits)</option> </option>
<option value="neutral">
Neutral (Frictionless / Standard)
</option>
<option value="negative">
Negative (Lower Score is Better / Demerits)
</option>
</select> </select>
<div <div
v-if="form.errors.alignment" v-if="form.errors.alignment"
@@ -106,8 +111,7 @@ function submit() {
<!-- Media Uploads for Ledgers --> <!-- Media Uploads for Ledgers -->
<div class="c-create-ledger-form__field"> <div class="c-create-ledger-form__field">
<label <label class="c-create-ledger-form__label"
class="c-create-ledger-form__label"
>Attach Cover/Rules Media</label >Attach Cover/Rules Media</label
> >
<input <input
@@ -126,9 +130,10 @@ function submit() {
:key="index" :key="index"
class="c-create-ledger-form__media-preview-item" class="c-create-ledger-form__media-preview-item"
> >
<span class="c-create-ledger-form__media-preview-name">{{ <span
file.name class="c-create-ledger-form__media-preview-name"
}}</span> >{{ file.name }}</span
>
<button <button
type="button" type="button"
@click="removeLedgerFile(index)" @click="removeLedgerFile(index)"
@@ -163,11 +168,15 @@ function submit() {
} }
.c-create-ledger-form__card { .c-create-ledger-form__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-create-ledger-form__body { .c-create-ledger-form__body {
@apply p-6 text-gray-900 dark:text-gray-100; @apply p-6;
color: var(--foreground);
} }
.c-create-ledger-form__title { .c-create-ledger-form__title {
@@ -183,19 +192,29 @@ function submit() {
} }
.c-create-ledger-form__label { .c-create-ledger-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300; @apply block text-sm font-medium;
color: var(--foreground);
} }
.c-create-ledger-form__input { .c-create-ledger-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 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__textarea { .c-create-ledger-form__textarea {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600; @apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__select { .c-create-ledger-form__select {
@apply mt-1 block w-full rounded-md border 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; @apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
border-color: var(--border);
background-color: var(--background);
color: var(--foreground);
} }
.c-create-ledger-form__file-input { .c-create-ledger-form__file-input {
-24
View File
@@ -20,27 +20,3 @@ withDefaults(defineProps<Props>(), {
</p> </p>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-heading {
@apply mb-8 space-y-0.5;
}
.c-heading--small {
@apply mb-0 space-y-0;
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
}
.c-heading--small .c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
.c-heading__description {
@apply text-sm text-muted-foreground;
}
</style>
-8
View File
@@ -11,11 +11,3 @@ defineProps<{
</p> </p>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-input-error__message {
@apply text-sm text-red-600 dark:text-red-500;
}
</style>
+14 -10
View File
@@ -17,9 +17,7 @@ defineProps<{
<template> <template>
<div class="c-ledger-list"> <div class="c-ledger-list">
<div class="c-ledger-list__header"> <div class="c-ledger-list__header">
<h4 class="c-ledger-list__title"> <h4 class="c-ledger-list__title">Ledgers</h4>
Ledgers
</h4>
</div> </div>
<ul class="c-ledger-list__grid"> <ul class="c-ledger-list__grid">
<li <li
@@ -107,7 +105,8 @@ defineProps<{
} }
.c-ledger-list__title { .c-ledger-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100; @apply text-lg font-medium;
color: var(--foreground);
} }
.c-ledger-list__grid { .c-ledger-list__grid {
@@ -115,15 +114,20 @@ defineProps<{
} }
.c-ledger-list__item { .c-ledger-list__item {
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700; @apply p-6;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-ledger-list__item-name { .c-ledger-list__item-name {
@apply text-lg font-semibold; @apply text-lg font-semibold;
color: var(--foreground);
} }
.c-ledger-list__item-score { .c-ledger-list__item-score {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400; @apply mt-2 text-sm;
color: var(--muted-foreground);
} }
.c-ledger-list__alignment-wrapper { .c-ledger-list__alignment-wrapper {
@@ -131,19 +135,19 @@ defineProps<{
} }
.c-ledger-list__alignment-badge { .c-ledger-list__alignment-badge {
@apply text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded; @apply rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase;
} }
.c-ledger-list__alignment-badge--positive { .c-ledger-list__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20; @apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
} }
.c-ledger-list__alignment-badge--negative { .c-ledger-list__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20; @apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
} }
.c-ledger-list__alignment-badge--neutral { .c-ledger-list__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20; @apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
} }
.c-ledger-list__media-list { .c-ledger-list__media-list {
+7 -5
View File
@@ -50,12 +50,16 @@ function getAmountClass(amount: number): string {
const alignment = props.ledgerAlignment || 'neutral'; const alignment = props.ledgerAlignment || 'neutral';
if (alignment === 'positive') { if (alignment === 'positive') {
return amount > 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative'; return amount > 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
} }
if (alignment === 'negative') { if (alignment === 'negative') {
// Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable // Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable
return amount < 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative'; return amount < 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
} }
// Neutral alignment // Neutral alignment
@@ -65,9 +69,7 @@ function getAmountClass(amount: number): string {
<template> <template>
<div class="c-mutation-list"> <div class="c-mutation-list">
<h4 class="c-mutation-list__title"> <h4 class="c-mutation-list__title">Mutations</h4>
Mutations
</h4>
<ul class="c-mutation-list__list"> <ul class="c-mutation-list__list">
<li <li
v-for="mutation in mutations" v-for="mutation in mutations"
+8 -5
View File
@@ -9,9 +9,7 @@ defineProps<{
<template> <template>
<div class="c-participants-list"> <div class="c-participants-list">
<h4 class="c-participants-list__title"> <h4 class="c-participants-list__title">Participants</h4>
Participants
</h4>
<ul class="c-participants-list__grid"> <ul class="c-participants-list__grid">
<li <li
v-for="participant in participants" v-for="participant in participants"
@@ -32,7 +30,8 @@ defineProps<{
} }
.c-participants-list__title { .c-participants-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100; @apply text-lg font-medium;
color: var(--foreground);
} }
.c-participants-list__grid { .c-participants-list__grid {
@@ -40,6 +39,10 @@ defineProps<{
} }
.c-participants-list__item { .c-participants-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden p-4;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
} }
</style> </style>
-20
View File
@@ -40,23 +40,3 @@ defineExpose({
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.password-input {
@apply relative;
}
:deep(.password-input__field) {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none;
}
.password-input__icon {
@apply size-4;
}
</style>
-8
View File
@@ -23,11 +23,3 @@ defineProps<Props>();
<slot /> <slot />
</Link> </Link>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-text-link {
@apply text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500;
}
</style>
-20
View File
@@ -34,23 +34,3 @@ const showAvatar = computed(
<span v-if="showEmail" class="user-info__email">{{ user.email }}</span> <span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.user-info__avatar {
@apply h-8 w-8 overflow-hidden rounded-lg;
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
}
.user-info__name {
@apply truncate font-medium;
}
.user-info__email {
@apply truncate text-xs text-muted-foreground;
}
</style>
@@ -32,47 +32,3 @@ defineProps<{
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10;
}
.auth-layout__container {
@apply w-full max-w-sm;
}
.auth-layout__inner {
@apply flex flex-col gap-8;
}
.auth-layout__header {
@apply flex flex-col items-center gap-4;
}
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
}
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center rounded-md;
}
.auth-layout__logo {
@apply size-9 fill-current text-[var(--foreground)] dark:text-white;
}
.auth-layout__title-box {
@apply space-y-2 text-center;
}
.auth-layout__title {
@apply text-xl font-medium;
}
.auth-layout__description {
@apply text-center text-sm text-muted-foreground;
}
</style>
+64 -55
View File
@@ -21,18 +21,20 @@ defineProps<{
url: string; url: string;
unread_count: number; unread_count: number;
context_activities: Array<{ context_activities: Array<{
id: string; id: number;
type: string; content: string;
description: string; user: { name: string } | null;
user: { name: string }; subject: { name: string; url: string } | null;
created_at: string; created_at: string;
url: string;
}>; }>;
new_activities: Array<{ new_activities: Array<{
id: string; id: number;
type: string; content: string;
description: string; user: { name: string } | null;
user: { name: string }; subject: { name: string; url: string } | null;
created_at: string; created_at: string;
url: string;
}>; }>;
}>; }>;
}>(); }>();
@@ -62,7 +64,7 @@ function formatTime(isoString: string): string {
'c-dashboard__badge-type', 'c-dashboard__badge-type',
entity.type === 'Dynamic' entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic' ? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger' : 'c-dashboard__badge-type--ledger',
]" ]"
> >
{{ entity.type }} {{ entity.type }}
@@ -71,7 +73,10 @@ function formatTime(isoString: string): string {
{{ entity.unread_count }} New {{ entity.unread_count }} New
</span> </span>
</div> </div>
<Link :href="entity.url" class="c-dashboard__entity-link"> <Link
:href="entity.url"
class="c-dashboard__entity-link"
>
<h3 class="c-dashboard__entity-title"> <h3 class="c-dashboard__entity-title">
{{ entity.name }} {{ entity.name }}
</h3> </h3>
@@ -85,17 +90,19 @@ function formatTime(isoString: string): string {
: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"
> >
<div class="c-dashboard__activity-meta"> <Link :href="activity.url" class="block">
<span class="c-dashboard__activity-user"> <div class="c-dashboard__activity-meta">
{{ activity.user.name }} <span class="c-dashboard__activity-user">
</span> {{ activity.user?.name || 'System' }}
<span class="c-dashboard__activity-time"> </span>
{{ formatTime(activity.created_at) }} <span class="c-dashboard__activity-time">
</span> {{ formatTime(activity.created_at) }}
</div> </span>
<p class="c-dashboard__activity-desc"> </div>
{{ activity.description }} <p class="c-dashboard__activity-desc">
</p> {{ activity.content }}
</p>
</Link>
</div> </div>
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
@@ -103,7 +110,9 @@ function formatTime(isoString: string): string {
v-if="entity.context_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 Activity Below</span> <span class="c-dashboard__divider-text"
>New Activity Below</span
>
</div> </div>
<!-- New / Unread Activities --> <!-- New / Unread Activities -->
@@ -112,18 +121,20 @@ function formatTime(isoString: string): string {
: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"
> >
<div class="c-dashboard__activity-meta"> <Link :href="activity.url" class="block">
<span class="c-dashboard__activity-user"> <div class="c-dashboard__activity-meta">
{{ activity.user.name }} <span class="c-dashboard__activity-user">
</span> {{ activity.user?.name || 'System' }}
<span class="c-dashboard__activity-time"> </span>
{{ formatTime(activity.created_at) }} <span class="c-dashboard__activity-time">
</span> {{ formatTime(activity.created_at) }}
<span class="c-dashboard__new-badge">NEW</span> </span>
</div> <span class="c-dashboard__new-badge">NEW</span>
<p class="c-dashboard__activity-desc"> </div>
{{ activity.description }} <p class="c-dashboard__activity-desc">
</p> {{ activity.content }}
</p>
</Link>
</div> </div>
</div> </div>
</div> </div>
@@ -131,9 +142,7 @@ function formatTime(isoString: string): string {
<!-- Empty Caught-Up State --> <!-- Empty Caught-Up State -->
<div v-else class="c-dashboard__empty-state"> <div v-else class="c-dashboard__empty-state">
<div class="c-dashboard__empty-icon"> <div class="c-dashboard__empty-icon">🔒</div>
🔒
</div>
<p class="c-dashboard__empty-text"> <p class="c-dashboard__empty-text">
All chambers are currently quiet. All chambers are currently quiet.
</p> </p>
@@ -149,7 +158,7 @@ function formatTime(isoString: string): string {
@reference "../../css/app.css"; @reference "../../css/app.css";
.c-dashboard { .c-dashboard {
@apply py-8 px-4 sm:px-6 lg:px-8; @apply px-4 py-8 sm:px-6 lg:px-8;
} }
.c-dashboard__container { .c-dashboard__container {
@@ -157,7 +166,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__title { .c-dashboard__title {
@apply text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 mb-6; @apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
} }
.c-dashboard__grid { .c-dashboard__grid {
@@ -169,15 +178,15 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__card-header { .c-dashboard__card-header {
@apply p-6 border-b border-neutral-100 dark:border-neutral-800/30; @apply border-b border-neutral-100 p-6 dark:border-neutral-800/30;
} }
.c-dashboard__entity-meta { .c-dashboard__entity-meta {
@apply flex items-center gap-2 mb-2; @apply mb-2 flex items-center gap-2;
} }
.c-dashboard__badge-type { .c-dashboard__badge-type {
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded; @apply rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase;
} }
.c-dashboard__badge-type--dynamic { .c-dashboard__badge-type--dynamic {
@@ -189,7 +198,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__unread-count { .c-dashboard__unread-count {
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-400; @apply rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-bold tracking-wider text-red-800 uppercase dark:bg-red-950/20 dark:text-red-400;
} }
.c-dashboard__entity-link { .c-dashboard__entity-link {
@@ -201,23 +210,23 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__activity-list { .c-dashboard__activity-list {
@apply flex-1 p-6 space-y-4; @apply flex-1 space-y-4 p-6;
} }
.c-dashboard__activity-item { .c-dashboard__activity-item {
@apply p-4 rounded-lg border; @apply rounded-lg border p-4;
} }
.c-dashboard__activity-item--read { .c-dashboard__activity-item--read {
@apply bg-neutral-50/50 border-neutral-200 opacity-60 dark:bg-neutral-950/20 dark:border-neutral-800/50; @apply border-neutral-200 bg-neutral-50/50 opacity-60 dark:border-neutral-800/50 dark:bg-neutral-950/20;
} }
.c-dashboard__activity-item--unread { .c-dashboard__activity-item--unread {
@apply bg-red-50/10 border-red-200/50 dark:bg-red-950/5 dark:border-red-900/30; @apply border-red-200/50 bg-red-50/10 dark:border-red-900/30 dark:bg-red-950/5;
} }
.c-dashboard__activity-meta { .c-dashboard__activity-meta {
@apply flex items-center gap-2 mb-1.5 text-xs; @apply mb-1.5 flex items-center gap-2 text-xs;
} }
.c-dashboard__activity-user { .c-dashboard__activity-user {
@@ -229,7 +238,7 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__new-badge { .c-dashboard__new-badge {
@apply ml-auto text-[9px] font-bold text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-950/30 px-1 py-0.5 rounded; @apply ml-auto rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-600 dark:bg-red-950/30 dark:text-red-400;
} }
.c-dashboard__activity-desc { .c-dashboard__activity-desc {
@@ -237,11 +246,11 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__divider { .c-dashboard__divider {
@apply relative flex items-center justify-center my-4; @apply relative my-4 flex items-center justify-center;
} }
.c-dashboard__divider-text { .c-dashboard__divider-text {
@apply bg-white dark:bg-neutral-900 px-3 text-[10px] font-bold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 z-10; @apply z-10 bg-white px-3 text-[10px] font-bold tracking-widest text-neutral-400 uppercase dark:bg-neutral-900 dark:text-neutral-500;
} }
.c-dashboard__divider::before { .c-dashboard__divider::before {
@@ -250,11 +259,11 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__empty-state { .c-dashboard__empty-state {
@apply flex flex-col items-center justify-center p-12 text-center rounded-2xl border border-dashed border-neutral-200 dark:border-neutral-800 min-h-[300px]; @apply flex min-h-[300px] flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-200 p-12 text-center dark:border-neutral-800;
} }
.c-dashboard__empty-icon { .c-dashboard__empty-icon {
@apply text-4xl mb-4; @apply mb-4 text-4xl;
} }
.c-dashboard__empty-text { .c-dashboard__empty-text {
@@ -262,6 +271,6 @@ function formatTime(isoString: string): string {
} }
.c-dashboard__empty-subtext { .c-dashboard__empty-subtext {
@apply mt-1 text-sm text-neutral-500 dark:text-neutral-500 max-w-md; @apply mt-1 max-w-md text-sm text-neutral-500 dark:text-neutral-500;
} }
</style> </style>
+9 -8
View File
@@ -30,13 +30,16 @@ function submit() {
<div class="c-dynamics-create__container"> <div class="c-dynamics-create__container">
<div class="c-dynamics-create__card"> <div class="c-dynamics-create__card">
<div class="c-dynamics-create__body"> <div class="c-dynamics-create__body">
<h3 class="c-dynamics-create__title">Create a New Dynamic</h3> <h3 class="c-dynamics-create__title">
Create a New Dynamic
</h3>
<form @submit.prevent="submit" class="c-dynamics-create__form"> <form
@submit.prevent="submit"
class="c-dynamics-create__form"
>
<div class="c-dynamics-create__field"> <div class="c-dynamics-create__field">
<label <label for="name" class="c-dynamics-create__label"
for="name"
class="c-dynamics-create__label"
>Name</label >Name</label
> >
<input <input
@@ -54,9 +57,7 @@ function submit() {
</div> </div>
<div class="c-dynamics-create__field"> <div class="c-dynamics-create__field">
<label <label for="rules" class="c-dynamics-create__label"
for="rules"
class="c-dynamics-create__label"
>Rules</label >Rules</label
> >
<textarea <textarea
+3 -1
View File
@@ -51,7 +51,9 @@ const breadcrumbs = [
</div> </div>
</div> </div>
<div v-else> <div v-else>
<p class="c-dynamics-index__empty">You don't have any dynamics yet.</p> <p class="c-dynamics-index__empty">
You don't have any dynamics yet.
</p>
</div> </div>
</div> </div>
</div> </div>
+170
View File
@@ -0,0 +1,170 @@
<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;
};
}>();
const form = useForm({
email: '',
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() {
form.post(route('dynamics.invitations.store', props.dynamic.id), {
onSuccess: () => form.reset(),
});
}
</script>
<template>
<Head title="Invite User" />
<div class="c-invite-user">
<div class="c-invite-user__container">
<div class="c-invite-user__card">
<div class="c-invite-user__body">
<h3 class="c-invite-user__title">
Invite User to {{ dynamic.name }}
</h3>
<form
@submit.prevent="submit"
class="c-invite-user__form"
>
<div class="c-invite-user__field">
<label
for="email"
class="c-invite-user__label"
>Email Address</label
>
<input
v-model="form.email"
id="email"
type="email"
required
class="c-invite-user__input"
/>
<div
v-if="form.errors.email"
class="c-invite-user__error"
>
{{ form.errors.email }}
</div>
</div>
<div class="c-invite-user__field">
<label
for="role"
class="c-invite-user__label"
>Role</label
>
<select
v-model="form.role"
id="role"
class="c-invite-user__select"
>
<option value="owner">Owner (Full Control)</option>
<option value="participant">Participant</option>
<option value="editor">Editor</option>
<option value="viewer">Viewer</option>
</select>
<div
v-if="form.errors.role"
class="c-invite-user__error"
>
{{ form.errors.role }}
</div>
</div>
<div class="c-invite-user__actions">
<button
type="submit"
:disabled="form.processing"
class="c-invite-user__submit-btn"
>
Send Invitation
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-invite-user {
@apply py-12;
}
.c-invite-user__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-invite-user__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-invite-user__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-invite-user__title {
@apply text-lg font-medium;
}
.c-invite-user__form {
@apply mt-6 space-y-6;
}
.c-invite-user__field {
@apply block;
}
.c-invite-user__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-invite-user__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-invite-user__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-invite-user__error {
@apply text-sm text-red-600;
}
.c-invite-user__actions {
@apply flex items-center gap-4;
}
.c-invite-user__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>
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
import { defineProps } from 'vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
};
predefined_mutations: Array<{
id: number;
name: string;
description: string;
amount: number;
type: string;
}>;
}>();
const form = useForm({
name: '',
description: '',
amount: 0,
type: 'reward',
});
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),
},
];
function submit() {
form.post(route('dynamics.predefined-mutations.store', props.dynamic.id), {
onSuccess: () => form.reset(),
});
}
</script>
<template>
<Head title="Predefined Mutations" />
<AppLayout :breadcrumbs="breadcrumbs">
<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 {{ dynamic.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="c-predefined-mutations__item-amount">
{{ mutation.amount }}
</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__field">
<label
for="type"
class="c-predefined-mutations__label"
>Type</label
>
<select
v-model="form.type"
id="type"
class="c-predefined-mutations__select"
>
<option value="reward">Reward</option>
<option value="penalty">Penalty</option>
</select>
</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>
</AppLayout>
</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__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__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-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>
+137
View File
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
const props = defineProps<{
dynamic: {
id: number;
name: string;
rules: string;
};
}>();
const form = useForm({
name: props.dynamic.name,
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() {
form.patch(route('dynamics.update', props.dynamic.id));
}
</script>
<template>
<Head title="Dynamic Settings" />
<div class="c-dynamic-settings">
<div class="c-dynamic-settings__container">
<div class="c-dynamic-settings__card">
<div class="c-dynamic-settings__body">
<h3 class="c-dynamic-settings__title">Dynamic Settings</h3>
<form @submit.prevent="submit" class="c-dynamic-settings__form">
<div class="c-dynamic-settings__field">
<label for="name" class="c-dynamic-settings__label">Name</label>
<input v-model="form.name" id="name" type="text" class="c-dynamic-settings__input" />
<div v-if="form.errors.name" class="c-dynamic-settings__error">
{{ form.errors.name }}
</div>
</div>
<div class="c-dynamic-settings__field">
<label for="rules" class="c-dynamic-settings__label">Rules</label>
<textarea v-model="form.rules" id="rules" rows="4" class="c-dynamic-settings__textarea"></textarea>
<div v-if="form.errors.rules" class="c-dynamic-settings__error">
{{ form.errors.rules }}
</div>
</div>
<div class="c-dynamic-settings__actions">
<button type="submit" :disabled="form.processing" class="c-dynamic-settings__submit-btn">
Save
</button>
</div>
</form>
</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>
</template>
<style scoped>
@reference "../../../css/app.css";
.c-dynamic-settings {
@apply py-12;
}
.c-dynamic-settings__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamic-settings__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-dynamic-settings__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-dynamic-settings__title {
@apply text-lg font-medium;
}
.c-dynamic-settings__form {
@apply mt-6 space-y-6;
}
.c-dynamic-settings__field {
@apply block;
}
.c-dynamic-settings__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-dynamic-settings__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-dynamic-settings__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-dynamic-settings__error {
@apply text-sm text-red-600;
}
.c-dynamic-settings__actions {
@apply flex items-center gap-4;
}
.c-dynamic-settings__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>
+40 -13
View File
@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import Chat from '@/components/Chat.vue'; import Chat from '@/components/Chat.vue';
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
import LedgerList from '@/components/LedgerList.vue';
import ParticipantsList from '@/components/ParticipantsList.vue'; import ParticipantsList from '@/components/ParticipantsList.vue';
import LedgerList from '@/components/LedgerList.vue';
import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{ const props = defineProps<{
dynamic: { dynamic: {
@@ -17,9 +16,11 @@ const props = defineProps<{
id: number; id: number;
name: string; name: string;
score: number; score: number;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}; };
isOwner: boolean;
}>(); }>();
const breadcrumbs = [ const breadcrumbs = [
@@ -41,10 +42,17 @@ const breadcrumbs = [
<div class="c-dynamic-show__container"> <div class="c-dynamic-show__container">
<div class="c-dynamic-show__card"> <div class="c-dynamic-show__card">
<div class="c-dynamic-show__body"> <div class="c-dynamic-show__body">
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3> <div class="flex justify-between items-start">
<p class="c-dynamic-show__rules"> <div>
{{ dynamic.rules }} <h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
</p> <p class="c-dynamic-show__rules">
{{ dynamic.rules }}
</p>
</div>
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
Settings
</InertiaLink>
</div>
</div> </div>
</div> </div>
@@ -57,8 +65,14 @@ const breadcrumbs = [
<!-- Ledgers List Component --> <!-- Ledgers List Component -->
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" /> <LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
<!-- Create Ledger Form Component --> <div v-if="isOwner" class="mt-8 flex gap-4">
<CreateLedgerForm :dynamic-id="dynamic.id" /> <InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn">
Invite User
</InertiaLink>
<InertiaLink :href="route('dynamics.ledgers.create', dynamic.id)" class="c-dynamic-show__action-btn">
Create Ledger
</InertiaLink>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -75,11 +89,15 @@ const breadcrumbs = [
} }
.c-dynamic-show__card { .c-dynamic-show__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800; @apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
} }
.c-dynamic-show__body { .c-dynamic-show__body {
@apply p-6 text-gray-900 dark:text-gray-100; @apply p-6;
color: var(--foreground);
} }
.c-dynamic-show__title { .c-dynamic-show__title {
@@ -87,6 +105,15 @@ const breadcrumbs = [
} }
.c-dynamic-show__rules { .c-dynamic-show__rules {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400; @apply mt-2 text-sm;
color: var(--muted-foreground);
}
.c-dynamic-show__settings-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-dynamic-show__action-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> </style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
import AppLayout from '@/layouts/AppLayout.vue';
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
const props = defineProps<{
dynamic: {
id: number;
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>
<template>
<Head title="Create Ledger" />
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<CreateLedgerForm :dynamic-id="dynamic.id" />
</div>
</div>
</template>
+11 -39
View File
@@ -188,7 +188,8 @@ function isOwnerUser(userId: number): boolean {
v-else-if="ledger.alignment === 'negative'" v-else-if="ledger.alignment === 'negative'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative" class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative"
> >
Negative Alignment A lower score is better (demerits / infractions). Negative Alignment A lower score is better
(demerits / infractions).
</span> </span>
<span <span
v-else v-else
@@ -249,29 +250,20 @@ function isOwnerUser(userId: number): boolean {
</div> </div>
<!-- Lightbox Modal --> <!-- Lightbox Modal -->
<div <div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
v-if="activeLightboxUrl" <button @click="closeLightbox" class="c-lightbox__close"></button>
class="c-ledger-show__lightbox" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button
@click="closeLightbox"
class="c-ledger-show__lightbox-close"
>
</button>
<div class="c-ledger-show__lightbox-content" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-ledger-show__lightbox-img" class="c-lightbox__image"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-ledger-show__lightbox-video" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
@@ -325,19 +317,19 @@ function isOwnerUser(userId: number): boolean {
} }
.c-ledger-show__alignment-badge { .c-ledger-show__alignment-badge {
@apply text-xs font-semibold uppercase tracking-wide px-2.5 py-1 rounded; @apply rounded px-2.5 py-1 text-xs font-semibold tracking-wide uppercase;
} }
.c-ledger-show__alignment-badge--positive { .c-ledger-show__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20; @apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
} }
.c-ledger-show__alignment-badge--negative { .c-ledger-show__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20; @apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
} }
.c-ledger-show__alignment-badge--neutral { .c-ledger-show__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20; @apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
} }
.c-ledger-show__media-list { .c-ledger-show__media-list {
@@ -363,24 +355,4 @@ function isOwnerUser(userId: number): boolean {
.c-ledger-show__media-video-overlay { .c-ledger-show__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white; @apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
} }
.c-ledger-show__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-ledger-show__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-ledger-show__lightbox-content {
@apply max-h-full max-w-full;
}
.c-ledger-show__lightbox-img {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-ledger-show__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style> </style>
@@ -0,0 +1,18 @@
<x-mail::message>
# You have been invited!
Hello,
**{{ $inviterName }}** has invited you to join their Dynamic: **{{ $dynamicName }}** as a **{{ strtoupper($role) }}**.
Only the user registered with this email address can accept this invitation. The invitation link will be valid for 7 days.
<x-mail::button :url="$acceptUrl">
Accept Invitation
</x-mail::button>
If you do not have an account yet, please register using this email address first, then click the button above.
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
+16 -2
View File
@@ -12,12 +12,26 @@ Route::inertia('/', 'Welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('dynamics', DynamicController::class); Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
Route::resource('dynamics.ledgers', LedgerController::class)->scoped(); Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
Route::resource('dynamics.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped(); Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
Route::get('/dynamics/{dynamic}/invitations/create', [\App\Http\Controllers\DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create');
Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store');
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store'); Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
}); });
Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept'])
->middleware(['auth', 'signed'])
->name('dynamics.invitations.accept');
\Illuminate\Support\Facades\Broadcast::routes(); \Illuminate\Support\Facades\Broadcast::routes();
require __DIR__.'/settings.php'; require __DIR__.'/settings.php';
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('user can register, log in, and log out', function () {
$this->browse(function (Browser $browser) {
// 1. Test Registration
$browser->visit('/register')
->waitForText('Create an account')
->type('name', 'New Browser User')
->type('email', 'newbrowseruser@example.com')
->type('password', 'password')
->type('password_confirmation', 'password')
->press('Create account')
->waitForLocation('/dashboard')
->assertPathIs('/dashboard')
->assertSee('New Browser User');
// 2. Test Logout
// Open the user menu (trigger button shows initials or user name)
$browser->click('button[aria-haspopup="menu"]')
->waitForText('Log out')
->clickLink('Log out')
->waitForLocation('/login') // Fortify logs out and redirects to login or home
->assertPathIs('/login');
// 3. Test Login
$browser->type('email', 'newbrowseruser@example.com')
->type('password', 'password')
->press('Log in')
->waitForLocation('/dashboard')
->assertPathIs('/dashboard')
->assertSee('New Browser User');
});
});
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('access control and actions are enforced for owners and participants', function () {
// Create database state
$owner = User::factory()->create([
'name' => 'Owner Alice',
'email' => 'alice-owner@example.com',
'password' => bcrypt('password'),
]);
$participant = User::factory()->create([
'name' => 'Participant Bob',
'email' => 'bob-sub@example.com',
'password' => bcrypt('password'),
]);
$outsider = User::factory()->create([
'name' => 'Outsider Charlie',
'email' => 'charlie-outsider@example.com',
'password' => bcrypt('password'),
]);
$dynamic = Dynamic::create([
'name' => 'Private Club',
'rules' => 'Strict access control.',
]);
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$ledger = Ledger::create([
'dynamic_id' => $dynamic->id,
'name' => 'Rules Compliance',
'rules' => 'Score rules.',
'score' => 100,
'alignment' => 'neutral',
]);
$this->browse(function (Browser $sessionOwner, Browser $sessionParticipant, Browser $sessionOutsider) use ($dynamic, $ledger, $owner, $participant, $outsider) {
// 1. Test Outsider trying to access dynamic they DO NOT belong to (should be forbidden / 403)
$sessionOutsider->loginAs($outsider)
->visit(route('dynamics.show', $dynamic))
->assertSee('403') // Laravel / Inertia forbidden page
->assertDontSee('Private Club');
// 2. Test Participant accessing dynamic they DO belong to (should be allowed)
$sessionParticipant->loginAs($participant)
->visit(route('dynamics.show', $dynamic))
->waitForText('Private Club')
->assertSee('Private Club')
->assertSee('Participant Bob');
// 3. Test Participant adding a mutation suggestion
$sessionParticipant->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
->waitForText('Add Mutation')
->type('amount', '20')
->type('description', 'Cleaned the main room')
->press('Add Mutation')
->waitForText('PENDING')
->assertSee('PENDING') // Mutation should show up as pending
->assertDontSee('Approve'); // Standard participant should NOT see approve button!
// 4. Test Owner logging in, seeing the pending suggestion, and approving it!
$sessionOwner->loginAs($owner)
->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
->waitForText('Cleaned the main room')
->assertSee('PENDING')
->assertSee('Approve') // Owner should see the Approve button!
->press('Approve')
->waitForText('Score: 120') // Score updated from 100 to 120 after approval!
->assertDontSee('PENDING'); // No longer pending!
});
});
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Tests\Browser;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
test('multiple sessions can communicate in real time through websockets', function () {
// 1. Create realistic database state
$owner = User::factory()->create([
'name' => 'TU Test User',
'email' => 'test-owner@example.com',
'password' => bcrypt('password'),
]);
$participant = User::factory()->create([
'name' => 'Submissive Bob',
'email' => 'test-sub@example.com',
'password' => bcrypt('password'),
]);
$dynamic = Dynamic::create([
'name' => 'The Test Sanctuary',
'rules' => 'Rules for realtime testing.',
]);
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
// 2. Spawn two separate browser sessions/browsers in parallel
$this->browse(function (Browser $sessionA, Browser $sessionB) use ($dynamic, $owner, $participant) {
// --- SESSION A: Owner ---
$sessionA->loginAs($owner)
->visit(route('dynamics.show', $dynamic))
->waitForText('The Test Sanctuary')
->assertSee('TU Test User'); // Verify loaded in as Owner
// --- SESSION B: Participant ---
$sessionB->loginAs($participant)
->visit(route('dynamics.show', $dynamic))
->waitForText('The Test Sanctuary')
->assertSee('Submissive Bob'); // Verify loaded in as Submissive/Participant
// --- REAL-TIME COMMUNICATING ---
// Owner types and sends a message in chat
$sessionA->type('#content', 'Hello Submissive Bob, did you complete your daily chores?')
->click('.c-chat__button')
->waitForText('Hello Submissive Bob');
// Since websockets broadcast in real-time, Session B receives it without reloading
$sessionB->waitForText('Hello Submissive Bob', 5)
->assertSee('Hello Submissive Bob, did you complete your daily chores?');
// Participant replies in real-time
$sessionB->type('#content', 'Yes Master, everything is complete and logged in the ledger!')
->click('.c-chat__button')
->waitForText('Yes Master, everything is complete');
// Session A receives the reply in real-time without reloading
$sessionA->waitForText('Yes Master, everything is complete', 5)
->assertSee('Yes Master, everything is complete and logged in the ledger!');
});
});
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Tests;
use Laravel\Dusk\TestCase as BaseTestCase;
use Facebook\WebDriver\Chrome\ChromeOptions;
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
abstract class DuskTestCase extends BaseTestCase
{
/**
* Prepare for Dusk test execution.
*
* @beforeClass
*/
public static function prepare(): void
{
if (! static::runningInSail()) {
static::startChromeDriver();
}
}
/**
* Create the RemoteWebDriver instance.
*/
protected function driver(): RemoteWebDriver
{
$options = (new ChromeOptions)->addArguments(collect([
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
'--disable-gpu',
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
])->unless(static::runningInSail(), function (collect $arguments) {
return $arguments->push('--disable-smooth-scrolling');
})->all());
return RemoteWebDriver::create(
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options)
);
}
}
+2 -2
View File
@@ -104,9 +104,9 @@ test('dashboard groups and filters unread entities correctly based on cursor', f
->where('unreadEntities.0.name', 'Testing Dynamic') ->where('unreadEntities.0.name', 'Testing Dynamic')
->where('unreadEntities.0.unread_count', 1) ->where('unreadEntities.0.unread_count', 1)
->has('unreadEntities.0.context_activities', 1) // Should have old message as context ->has('unreadEntities.0.context_activities', 1) // Should have old message as context
->where('unreadEntities.0.context_activities.0.description', 'Old message context') ->where('unreadEntities.0.context_activities.0.content', 'Old message context')
->has('unreadEntities.0.new_activities', 1) // Should have unread message ->has('unreadEntities.0.new_activities', 1) // Should have unread message
->where('unreadEntities.0.new_activities.0.description', 'New unread message alert') ->where('unreadEntities.0.new_activities.0.content', 'New unread message alert')
); );
// Now visit the Dynamic, which clears the unread count // Now visit the Dynamic, which clears the unread count
+103
View File
@@ -0,0 +1,103 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\DynamicInvitation;
use App\Mail\DynamicInvitationMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
test('only owners can invite other users to a dynamic', function () {
Mail::fake();
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
// 1. Participant tries to send an invite (forbidden)
$response = $this->actingAs($participant)
->post(route('dynamics.invitations.store', $dynamic), [
'email' => 'invitee@example.com',
'role' => 'participant',
]);
$response->assertStatus(403);
Mail::assertNothingSent();
// 2. Owner sends a valid invite (allowed)
$response = $this->actingAs($owner)
->post(route('dynamics.invitations.store', $dynamic), [
'email' => 'invitee@example.com',
'role' => 'participant',
]);
$response->assertRedirect();
$response->assertSessionHasNoErrors();
// Verify invitation is stored
$invitation = DynamicInvitation::firstWhere('email', 'invitee@example.com');
expect($invitation)->not->toBeNull();
expect($invitation->role)->toBe('participant');
expect($invitation->dynamic_id)->toBe($dynamic->id);
// Verify email was dispatched
Mail::assertSent(DynamicInvitationMail::class, function ($mail) use ($invitation) {
return $mail->hasTo($invitation->email);
});
});
test('only the user with the specified email address can accept the link', function () {
$owner = User::factory()->create();
$invitee = User::factory()->create(['email' => 'matching@example.com']);
$hijacker = User::factory()->create(['email' => 'hijacker@example.com']);
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
// Create a pending invitation
$invitation = $dynamic->invitations()->create([
'email' => 'matching@example.com',
'role' => 'editor',
'token' => 'secure_test_token_123',
'expires_at' => now()->addDays(7),
]);
// Generate a secure, valid signed URL
$signedUrl = URL::temporarySignedRoute(
'dynamics.invitations.accept',
$invitation->expires_at,
['token' => $invitation->token]
);
// 1. Unauthenticated user tries to accept (redirected to login)
$response = $this->get($signedUrl);
$response->assertRedirect('/login');
// 2. Different user (hijacker) tries to accept (forbidden / 403)
$response = $this->actingAs($hijacker)->get($signedUrl);
$response->assertStatus(403);
expect($dynamic->participants()->where('user_id', $hijacker->id)->exists())->toBeFalse();
// 3. Intended user accepts the signed invitation link (success)
$response = $this->actingAs($invitee)->get($signedUrl);
$response->assertRedirect(route('dynamics.show', $dynamic));
// Verify invitee is joined as a participant with the specified role
$isJoined = $dynamic->participants()
->where('user_id', $invitee->id)
->wherePivot('role', 'editor')
->exists();
expect($isJoined)->toBeTrue();
// Verify invitation is deleted from the database
expect(DynamicInvitation::where('token', 'secure_test_token_123')->exists())->toBeFalse();
// Verify system notification is added to Dynamic activity chat
$chatMessages = $dynamic->chat->messages;
expect($chatMessages)->not->toBeEmpty();
expect($chatMessages->last()->content)->toBe("{$invitee->name} joined the Dynamic as a EDITOR");
});
+57
View File
@@ -0,0 +1,57 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
test('dynamic owners can view ledger creation form and create ledgers', function () {
$owner = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$this->actingAs($owner);
// Can view form
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertOk();
// Can store ledger
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
'name' => 'Chores Ledger',
'rules' => 'Do the tasks.',
'alignment' => 'positive',
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect(route('dynamics.show', $dynamic->id));
$this->assertDatabaseHas('ledgers', [
'dynamic_id' => $dynamic->id,
'name' => 'Chores Ledger',
'alignment' => 'positive',
]);
});
test('non-owners cannot view ledger creation form or store ledgers', function () {
$owner = User::factory()->create();
$participant = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
$this->actingAs($participant);
// Cannot view form
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertStatus(403);
// Cannot store ledger
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
'name' => 'Illegal Ledger',
'rules' => 'This should fail.',
'alignment' => 'positive',
]);
$response->assertStatus(403);
$this->assertDatabaseMissing('ledgers', [
'name' => 'Illegal Ledger',
]);
});
+75
View File
@@ -0,0 +1,75 @@
<?php
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Models\Media;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
test('media can be attached to mutations, ledgers, and messages', function () {
Storage::fake('public');
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($user);
// 1. Test attaching media to a mutation
$file1 = UploadedFile::fake()->create('proof1.jpg', 100);
$file2 = UploadedFile::fake()->create('proof2.png', 100);
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
'amount' => 50,
'description' => 'Completed deep clean',
'media' => [$file1, $file2],
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
$mutation = Mutation::firstWhere('description', 'Completed deep clean');
expect($mutation)->not->toBeNull();
expect($mutation->media)->toHaveCount(2);
expect($mutation->media->first()->file_name)->toBe('proof1.jpg');
expect($mutation->media->last()->file_name)->toBe('proof2.png');
Storage::disk('public')->assertExists($mutation->media->first()->file_path);
Storage::disk('public')->assertExists($mutation->media->last()->file_path);
// 2. Test attaching media to a ledger
$file3 = UploadedFile::fake()->create('rules.jpg', 100);
$response = $this->post(route('dynamics.ledgers.store', $dynamic), [
'name' => 'Worship Ledger',
'rules' => 'Specific rules',
'alignment' => 'neutral',
'media' => [$file3],
]);
$response->assertSessionHasNoErrors();
$response->assertRedirect();
$newLedger = Ledger::firstWhere('name', 'Worship Ledger');
expect($newLedger)->not->toBeNull();
expect($newLedger->media)->toHaveCount(1);
expect($newLedger->media->first()->file_name)->toBe('rules.jpg');
// 3. Test attaching media to a chat message
$chat = $dynamic->chat;
$file4 = UploadedFile::fake()->create('chat_img.jpg', 100);
$response = $this->post(route('chats.messages.store', $chat), [
'content' => 'Check this out!',
'media' => [$file4],
]);
$response->assertRedirect();
$message = Message::firstWhere('content', 'Check this out!');
expect($message)->not->toBeNull();
expect($message->media)->toHaveCount(1);
expect($message->media->first()->file_name)->toBe('chat_img.jpg');
});