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
Daan Meijer a687d7ac4d docs: Update IDEA.md with session achievements and implementation details
linter / quality (push) Failing after 1m2s
tests / ci (8.3) (push) Failing after 52s
tests / ci (8.4) (push) Failing after 1m4s
tests / ci (8.5) (push) Failing after 1m5s
2026-06-15 23:41:11 +02:00
Daan Meijer f12639fb59 feat: Implement read cursor activity tracking and recent activity dashboard with context 2026-06-15 23:37:44 +02:00
Daan Meijer e32fbc10e0 style: Align negative ledger mutation score values and demerit math in seeder 2026-06-15 23:26:57 +02:00
Daan Meijer 94aae59f8e docs: Document clean environment test execution learnings in AGENTS.md 2026-06-15 23:24:26 +02:00
Daan Meijer 3dd7c48b08 feat: Implement Ledger alignment and correct corrective mutation authors in seeders 2026-06-15 23:22:08 +02:00
Daan Meijer bf7da48a58 style: Refactor Vue pages and custom layout components to scoped BEM 2026-06-15 23:13:54 +02:00
84 changed files with 3908 additions and 846 deletions
+7
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.
@@ -131,6 +137,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===
+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.
+32
View File
@@ -9,3 +9,35 @@ Dynamics have a rules-segment too. Not all people within a dynamic can edit thes
The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future. The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future.
Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features. Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features.
---
## Session Developments & Achievements
During this session, we successfully built out and verified several core architectural features of the Ledgerrz application:
1. **BEM Styling Refactoring (BDSM Theme first)**:
* Replaced inline Tailwind classes across all core Vue pages and layout components (`Dynamics/Index.vue`, `Dynamics/Create.vue`, `Dynamics/Show.vue`, `Ledgers/Show.vue`, `ParticipantsList.vue`, `LedgerList.vue`, `CreateLedgerForm.vue`, `AddMutationForm.vue`, and `MutationList.vue`) with structured BEM scoped CSS blocks.
* Utilized Tailwind v4 `@apply` and `@reference` inside `<style scoped>` blocks to prevent code duplication, enforce a dark-first BDSM aesthetic, and maintain clear separation of concerns.
2. **Ledger Alignment & Scoring Logic**:
* Added an `alignment` column (`positive`, `neutral`, `negative`) to Ledgers, affecting the color coding of mutations:
* *Positive Alignment*: higher score is better; additions are green (+), demerits/deductions are red (-).
* *Negative Alignment*: lower score is better (demerits); deductions are green (-) as they reduce demerits, and additions/infractions are red (+) as they increase demerits.
* *Neutral Alignment*: standard scoring, styled in gray/neutral colors.
* Added an "Alignment" dropdown selector in the ledger creation form.
3. **Demerit Authorship Correction**:
* Corrected database seeders so demerits/penalty mutations are authored by the Dominant/Owner user (`alice` or `testUser`) rather than the submissive (`bob`), aligning with realistic power exchange dynamics.
4. **Read Cursor & Dashboard Activities Highlights**:
* Designed and built a polymorphic `read_cursors` table to track a user's last-viewed timestamp on individual Dynamics and Ledgers.
* Developed `ActivityService` to chronological sort and partition dynamic activity (messages, ledger creation) and ledger activity (mutations, approval updates, and comments) into unread vs. read categories.
* Highlighted unread entities grouped together on the user's dashboard, displaying the **last two already-read items as muted context** followed by highlighted unread activities with a prominent `NEW` badge.
* Visiting a Dynamic/Ledger automatically updates its read cursor, removing it from subsequent dashboard loads.
* Dynamically shared the unread entity count as a global Inertia prop, rendering a vibrant red dot notification badge over a bell icon in the top-right of the header.
5. **Broadcasts, Environment & Verification**:
* Configured real-time notifications utilizing Laravel Reverb.
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors.
* Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**.
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\ActivityService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index(Request $request, ActivityService $activityService)
{
$user = $request->user();
$unreadEntities = $activityService->getUnreadEntitiesGrouped($user);
return Inertia::render('Dashboard', [
'unreadEntities' => $unreadEntities,
]);
}
}
+20 -4
View File
@@ -3,7 +3,9 @@
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;
@@ -44,10 +46,12 @@ class DynamicController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Dynamic $dynamic) public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
$activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([ $dynamic->load([
'ledgers.media', 'ledgers.media',
'participants', 'participants',
@@ -55,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,
]); ]);
} }
@@ -65,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!');
}
}
+10 -3
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreLedgerRequest; use App\Http\Requests\StoreLedgerRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Services\ActivityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia; use Inertia\Inertia;
@@ -24,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,
]);
} }
/** /**
@@ -53,10 +58,12 @@ class LedgerController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Request $request, Dynamic $dynamic, Ledger $ledger) public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService)
{ {
$this->authorize('view', $ledger); $this->authorize('view', $ledger);
$activityService->updateCursor($request->user(), $ledger);
$dynamic->load('chat', 'participants'); $dynamic->load('chat', 'participants');
$ledger->load([ $ledger->load([
@@ -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);
}
}
@@ -42,6 +42,14 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(), 'user' => $request->user(),
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'unreadNotificationsCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
$service = app(\App\Services\ActivityService::class);
return count($service->getUnreadEntitiesGrouped($request->user()));
},
]; ];
} }
} }
+2 -1
View File
@@ -14,7 +14,7 @@ class StoreLedgerRequest extends FormRequest
{ {
$dynamic = $this->route('dynamic'); $dynamic = $this->route('dynamic');
return $dynamic && $this->user()->can('view', $dynamic); return $dynamic && $this->user()->can('update', $dynamic);
} }
/** /**
@@ -27,6 +27,7 @@ class StoreLedgerRequest extends FormRequest
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'], 'rules' => ['nullable', 'string'],
'alignment' => ['required', 'string', 'in:positive,neutral,negative'],
'media' => ['nullable', 'array'], 'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'], 'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
]; ];
@@ -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();
}
}
+1
View File
@@ -18,6 +18,7 @@ class Ledger extends Model
'name', 'name',
'rules', 'rules',
'score', 'score',
'alignment',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo
+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);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ReadCursor extends Model
{
protected $fillable = [
'user_id',
'cursorable_id',
'cursorable_type',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function cursorable(): MorphTo
{
return $this->morphTo();
}
}
+5
View File
@@ -44,6 +44,11 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function readCursors()
{
return $this->hasMany(ReadCursor::class);
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
+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();
} }
/** /**
+174
View File
@@ -0,0 +1,174 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Models\ReadCursor;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ActivityService
{
/**
* Update the read cursor for a user on a specific entity.
*/
public function updateCursor(User $user, $entity): void
{
ReadCursor::updateOrCreate(
[
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
],
[
'read_at' => Carbon::now(),
]
);
}
/**
* Get the read cursor timestamp for a user on a specific entity.
*/
public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface
{
$cursor = ReadCursor::where([
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
])->first();
// If no cursor exists, default to epoch (all activities are unread)
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
}
public function createMessage($chat, $user, $content, $subject = null)
{
$message = $chat->messages()->create([
'user_id' => $user ? $user->id : null,
'content' => $content,
'subject_id' => $subject ? $subject->id : null,
'subject_type' => $subject ? get_class($subject) : null,
]);
return $message;
}
public function createMutation($ledger, $user, $type, $amount, $description, $status)
{
$mutation = $ledger->mutations()->create([
'user_id' => $user->id,
'type' => $type,
'amount' => $amount,
'description' => $description,
'status' => $status,
]);
return $mutation;
}
/**
* Retrieve all activities for a given entity.
*/
public function getActivitiesForEntity($entity): array
{
if ($entity instanceof Dynamic) {
$chatId = $entity->chat->id;
} elseif ($entity instanceof Ledger) {
$chatId = $entity->dynamic->chat->id;
} else {
return [];
}
$messages = Message::where('chat_id', $chatId)
->with(['user', 'subject'])
->latest()
->get();
return $messages->map(function ($message) {
$messageData = $message->toArray();
$messageData['url'] = $this->getUrlForMessage($message);
return $messageData;
})->all();
}
/**
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
*/
public function getUnreadEntitiesGrouped(User $user): array
{
$groupedEntities = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
foreach ($entities as $entity) {
$readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
}
return $groupedEntities;
}
/**
* Partition activities into read and unread, and construct the grouped entity metadata.
*/
private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void
{
$alreadyRead = [];
$unread = [];
foreach ($activities as $act) {
if (Carbon::parse($act['created_at'])->gt($readAt)) {
$unread[] = $act;
} else {
$alreadyRead[] = $act;
}
}
if (!empty($unread)) {
$context = array_slice($alreadyRead, 0, 2);
$groupedEntities[] = [
'id' => $entity->id,
'name' => $entity->name,
'type' => Str::afterLast($type, '\\'),
'url' => $url,
'unread_count' => count($unread),
'context_activities' => $context,
'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 '';
}
}
+6 -1
View File
@@ -18,11 +18,16 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [ $middleware
->web(append: [
HandleAppearance::class, HandleAppearance::class,
HandleInertiaRequests::class, HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class, AddLinkHeadersForPreloadedAssets::class,
])
->trustProxies(at: [
'172.16.0.0/12',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
+1
View File
@@ -30,6 +30,7 @@ class LedgerFactory extends Factory
]), ]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.', 'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'score' => 0, 'score' => 0,
'alignment' => 'neutral',
]; ];
} }
} }
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->string('alignment')->default('neutral')->after('rules');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('alignment');
});
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('read_cursors', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('cursorable');
$table->timestamp('read_at');
$table->timestamps();
$table->unique(['user_id', 'cursorable_id', 'cursorable_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('read_cursors');
}
};
@@ -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');
});
}
};
+41 -201
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([
@@ -94,6 +78,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Curfew Compliance', 'name' => 'Curfew Compliance',
'rules' => 'Must be in bed and checked in by 11:00 PM.', 'rules' => 'Must be in bed and checked in by 11:00 PM.',
'score' => 35, 'score' => 35,
'alignment' => 'positive',
]); ]);
$cleaningLedger = Ledger::create([ $cleaningLedger = Ledger::create([
@@ -101,155 +86,49 @@ class DatabaseSeeder extends Seeder
'name' => 'Dungeon Cleaning', 'name' => 'Dungeon Cleaning',
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.', 'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
'score' => 45, 'score' => 45,
'alignment' => 'neutral',
]); ]);
$etiquetteLedger = Ledger::create([ $etiquetteLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id, 'dynamic_id' => $velvetSanctuary->id,
'name' => 'Protocol & Etiquette', 'name' => 'Protocol & Etiquette',
'rules' => 'Address others by correct titles. -5 per infraction.', 'rules' => 'Address others by correct titles. +5 per infraction.',
'score' => -15, 'score' => 15,
'alignment' => 'negative',
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
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' => $bob->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Left keys in the locks unmonitored',
'status' => 'approved',
]);
// Seed Pending Mutation with its own Chat Messages! // Seed Pending Mutation with its own Chat Messages!
$pendingMutation = Mutation::create([ $pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending');
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'addition',
'amount' => 10,
'description' => 'Weekly chore submission - dusting shelves',
'status' => 'pending',
]);
// Pending mutation chat messages (chat is auto-created on booted)
$pendingMutationChat = $pendingMutation->chat; $pendingMutationChat = $pendingMutation->chat;
Message::create([ $activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.');
'chat_id' => $pendingMutationChat->id, $activityService->createMessage($pendingMutationChat, $alice, "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.");
'user_id' => $bob->id, $activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!');
'content' => 'I have finished the shelves. Please approve when convenient.',
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $alice->id,
'content' => "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.",
]);
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $bob->id,
'content' => 'Apologies, Ma\'am. I will re-wipe the top section immediately!',
]);
// Seed Etiquette Mutations // Seed Etiquette Mutations
Mutation::create([ $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved');
'ledger_id' => $etiquetteLedger->id, $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved');
'user_id' => $bob->id, $activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved');
'type' => 'penalty', $activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved');
'amount' => -5,
'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -5,
'description' => 'Spoke out of turn in general chat',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 5,
'description' => 'Excellent reciting of the house codes',
'status' => 'approved',
]);
// ---------------------------------------------------- // ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement // 3. Seed Dynamic 2: Obsidian Household Agreement
// ---------------------------------------------------- // ----------------------------------------------------
$obsidianHousehold = Dynamic::create([ $obsidianHousehold = Dynamic::create([
'name' => 'Obsidian Household Agreement', 'name' => 'Obsidian Household Agreement',
'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.", 'rules' => "1. All residents must do their fair share of maintenance.
2. Coffee machine must be refilled immediately when empty.",
]); ]);
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']); $obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
@@ -258,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([
@@ -288,6 +148,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Kitchen Chores', 'name' => 'Kitchen Chores',
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.', 'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
'score' => 40, 'score' => 40,
'alignment' => 'positive',
]); ]);
$coffeeLedger = Ledger::create([ $coffeeLedger = Ledger::create([
@@ -295,36 +156,15 @@ class DatabaseSeeder extends Seeder
'name' => 'Coffee Machine Maintenance', 'name' => 'Coffee Machine Maintenance',
'rules' => ' refill beans and descale monthly.', 'rules' => ' refill beans and descale monthly.',
'score' => 10, 'score' => 10,
'alignment' => 'neutral',
]); ]);
// Seed Chores Mutations // Seed Chores Mutations
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);
}
}
+2
View File
@@ -19,7 +19,9 @@ configureEcho({
forceTLS: false, forceTLS: false,
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
if (typeof window !== 'undefined') {
(window as any).echoConfigured = true; (window as any).echoConfigured = true;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
+87 -30
View File
@@ -41,55 +41,48 @@ function submit() {
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-add-mutation-form">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 class="c-add-mutation-form__title">Add Mutation</h4>
Add Mutation <form @submit.prevent="submit" class="c-add-mutation-form__form">
</h4> <div class="c-add-mutation-form__field">
<form <label for="amount" class="c-add-mutation-form__label"
@submit.prevent="submit"
class="mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div>
<label
for="amount"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Amount</label >Amount</label
> >
<input <input
v-model="form.amount" v-model="form.amount"
id="amount" id="amount"
type="number" type="number"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-add-mutation-form__input"
/> />
<div v-if="form.errors.amount" class="text-sm text-red-600"> <div
v-if="form.errors.amount"
class="c-add-mutation-form__error"
>
{{ form.errors.amount }} {{ form.errors.amount }}
</div> </div>
</div> </div>
<div> <div class="c-add-mutation-form__field">
<label <label for="description" class="c-add-mutation-form__label"
for="description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Description</label >Description</label
> >
<textarea <textarea
v-model="form.description" v-model="form.description"
id="description" id="description"
rows="4" rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-add-mutation-form__textarea"
></textarea> ></textarea>
<div <div
v-if="form.errors.description" v-if="form.errors.description"
class="text-sm text-red-600" class="c-add-mutation-form__error"
> >
{{ form.errors.description }} {{ form.errors.description }}
</div> </div>
</div> </div>
<!-- Media Uploads for Mutations --> <!-- Media Uploads for Mutations -->
<div> <div class="c-add-mutation-form__field">
<label <label class="c-add-mutation-form__label"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Proof Media (Photos/Videos)</label >Attach Proof Media (Photos/Videos)</label
> >
<input <input
@@ -97,24 +90,24 @@ function submit() {
multiple multiple
accept="image/*,video/*" accept="image/*,video/*"
@change="handleMutationFileChange" @change="handleMutationFileChange"
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" class="c-add-mutation-form__file-input"
/> />
<div <div
v-if="form.media.length > 0" v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2" class="c-add-mutation-form__media-preview-list"
> >
<div <div
v-for="(file, index) in form.media" v-for="(file, index) in form.media"
:key="index" :key="index"
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800" class="c-add-mutation-form__media-preview-item"
> >
<span class="max-w-[150px] truncate">{{ <span class="c-add-mutation-form__media-preview-name">{{
file.name file.name
}}</span> }}</span>
<button <button
type="button" type="button"
@click="removeMutationFile(index)" @click="removeMutationFile(index)"
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500" class="c-add-mutation-form__media-preview-remove"
> >
</button> </button>
@@ -122,11 +115,11 @@ function submit() {
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="c-add-mutation-form__actions">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300" class="c-add-mutation-form__submit-btn"
> >
Add Mutation Add Mutation
</button> </button>
@@ -134,3 +127,67 @@ function submit() {
</form> </form>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-add-mutation-form {
@apply mt-8;
}
.c-add-mutation-form__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-add-mutation-form__form {
@apply mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-add-mutation-form__field {
@apply block;
}
.c-add-mutation-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-add-mutation-form__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__textarea {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__file-input {
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
}
.c-add-mutation-form__media-preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-add-mutation-form__media-preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-add-mutation-form__media-preview-name {
@apply max-w-[150px] truncate;
}
.c-add-mutation-form__media-preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-add-mutation-form__error {
@apply text-sm text-red-600;
}
.c-add-mutation-form__actions {
@apply flex items-center gap-4;
}
.c-add-mutation-form__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>
-8
View File
@@ -22,11 +22,3 @@ const className = computed(() => props.class);
<slot /> <slot />
</main> </main>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
}
</style>
+31
View File
@@ -33,6 +33,7 @@ import {
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue'; import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl'; import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { route } from 'ziggy-js';
import { getInitials } from '@/composables/useInitials'; import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils'; import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
@@ -238,6 +239,36 @@ const rightNavItems: NavItem[] = [
</div> </div>
</div> </div>
<div
v-if="(page.props as any).unreadNotificationsCount > 0"
class="relative"
>
<Link
:href="route('dashboard')"
class="relative flex h-9 w-9 items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 opacity-80"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
>
{{ page.props.unreadNotificationsCount }}
</span>
</Link>
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger :as-child="true"> <DropdownMenuTrigger :as-child="true">
<Button <Button
+1 -21
View File
@@ -7,26 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="app-logo__icon" /> <AppLogoIcon class="app-logo__icon" />
</div> </div>
<div class="app-logo__text-container"> <div class="app-logo__text-container">
<span class="app-logo__text">Laravel Starter Kit</span> <span class="app-logo__text">Ledgerrz</span>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
}
.app-logo__icon {
@apply size-5 fill-current text-white dark:text-black;
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
}
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
}
</style>
+31 -11
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link } from '@inertiajs/vue3'; import { Link, router } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue'; import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
import { onMounted, onUnmounted } from 'vue';
import AppLogo from '@/components/AppLogo.vue'; import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue'; import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue'; import NavMain from '@/components/NavMain.vue';
@@ -13,11 +14,30 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
import { index as dynamics } from '@/routes/dynamics'; import { index as dynamics } from '@/routes/dynamics';
import type { NavItem } from '@/types'; import type { NavItem } from '@/types';
const { isMobile, setOpenMobile } = useSidebar();
let unregister: () => void;
onMounted(() => {
unregister = router.on('navigate', () => {
if (isMobile.value) {
setOpenMobile(false);
}
});
});
onUnmounted(() => {
if (unregister) {
unregister();
}
});
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
]; ];
const footerNavItems: NavItem[] = [ const footerNavItems: NavItem[] = [
{ // {
title: 'Repository', // title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit', // href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2, // icon: FolderGit2,
}, // },
{ // {
title: 'Documentation', // title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue', // href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen, // icon: BookOpen,
}, // },
]; ];
</script> </script>
@@ -23,15 +23,3 @@ withDefaults(
</div> </div>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
</style>
@@ -27,27 +27,3 @@ const tabs = [
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.appearance-tabs {
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
}
.appearance-tabs__tab {
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
}
.appearance-tabs__tab--active {
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
</style>
+49 -156
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,8 +104,20 @@ 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,
),
},
]"
> >
<!-- Standard User Chat Message -->
<template v-if="!message.content.startsWith('System:')">
<div class="c-chat__message-header"> <div class="c-chat__message-header">
<span class="c-chat__message-author">{{ <span class="c-chat__message-author">{{
message.user.name message.user.name
@@ -144,6 +160,25 @@ function closeLightbox() {
</div> </div>
</div> </div>
</div> </div>
</template>
<!-- Subtle Activity Log System Message -->
<template v-else>
<div class="c-chat__system-inner">
<Info class="c-chat__system-icon" />
<span class="c-chat__system-text">
{{ 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>
+148 -31
View File
@@ -9,6 +9,7 @@ const props = defineProps<{
const form = useForm({ const form = useForm({
name: '', name: '',
rules: '', rules: '',
alignment: 'neutral',
media: [] as File[], media: [] as File[],
}); });
@@ -34,58 +35,83 @@ function submit() {
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-create-ledger-form">
<div <div class="c-create-ledger-form__card">
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" <div class="c-create-ledger-form__body">
> <h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Ledger</h3>
<form @submit.prevent="submit" class="mt-6 space-y-6"> <form
<div> @submit.prevent="submit"
<label class="c-create-ledger-form__form"
for="name" >
class="block text-sm font-medium text-gray-700 dark:text-gray-300" <div class="c-create-ledger-form__field">
<label for="name" class="c-create-ledger-form__label"
>Name</label >Name</label
> >
<input <input
v-model="form.name" v-model="form.name"
id="name" id="name"
type="text" type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-create-ledger-form__input"
/> />
<div <div
v-if="form.errors.name" v-if="form.errors.name"
class="text-sm text-red-600" class="c-create-ledger-form__error"
> >
{{ form.errors.name }} {{ form.errors.name }}
</div> </div>
</div> </div>
<div> <div class="c-create-ledger-form__field">
<label <label for="rules" class="c-create-ledger-form__label"
for="rules"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label >Rules</label
> >
<textarea <textarea
v-model="form.rules" v-model="form.rules"
id="rules" id="rules"
rows="4" rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-create-ledger-form__textarea"
></textarea> ></textarea>
<div <div
v-if="form.errors.rules" v-if="form.errors.rules"
class="text-sm text-red-600" class="c-create-ledger-form__error"
> >
{{ form.errors.rules }} {{ form.errors.rules }}
</div> </div>
</div> </div>
<!-- Media Uploads for Ledgers --> <div class="c-create-ledger-form__field">
<div>
<label <label
class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="alignment"
class="c-create-ledger-form__label"
>Alignment</label
>
<select
v-model="form.alignment"
id="alignment"
class="c-create-ledger-form__select"
>
<option value="positive">
Positive (Higher Score is Better)
</option>
<option value="neutral">
Neutral (Frictionless / Standard)
</option>
<option value="negative">
Negative (Lower Score is Better / Demerits)
</option>
</select>
<div
v-if="form.errors.alignment"
class="c-create-ledger-form__error"
>
{{ form.errors.alignment }}
</div>
</div>
<!-- Media Uploads for Ledgers -->
<div class="c-create-ledger-form__field">
<label class="c-create-ledger-form__label"
>Attach Cover/Rules Media</label >Attach Cover/Rules Media</label
> >
<input <input
@@ -93,24 +119,25 @@ function submit() {
multiple multiple
accept="image/*,video/*" accept="image/*,video/*"
@change="handleLedgerFileChange" @change="handleLedgerFileChange"
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100" class="c-create-ledger-form__file-input"
/> />
<div <div
v-if="form.media.length > 0" v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2" class="c-create-ledger-form__media-preview-list"
> >
<div <div
v-for="(file, index) in form.media" v-for="(file, index) in form.media"
:key="index" :key="index"
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800" class="c-create-ledger-form__media-preview-item"
>
<span
class="c-create-ledger-form__media-preview-name"
>{{ file.name }}</span
> >
<span class="max-w-[150px] truncate">{{
file.name
}}</span>
<button <button
type="button" type="button"
@click="removeLedgerFile(index)" @click="removeLedgerFile(index)"
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500" class="c-create-ledger-form__media-preview-remove"
> >
</button> </button>
@@ -118,11 +145,11 @@ function submit() {
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="c-create-ledger-form__actions">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300" class="c-create-ledger-form__submit-btn"
> >
Create Ledger Create Ledger
</button> </button>
@@ -132,3 +159,93 @@ function submit() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-create-ledger-form {
@apply mt-8;
}
.c-create-ledger-form__card {
@apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-create-ledger-form__body {
@apply p-6;
color: var(--foreground);
}
.c-create-ledger-form__title {
@apply text-lg font-medium;
}
.c-create-ledger-form__form {
@apply mt-6 space-y-6;
}
.c-create-ledger-form__field {
@apply block;
}
.c-create-ledger-form__label {
@apply block text-sm font-medium;
color: var(--foreground);
}
.c-create-ledger-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-create-ledger-form__textarea {
@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 {
@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 {
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
}
.c-create-ledger-form__media-preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-create-ledger-form__media-preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-create-ledger-form__media-preview-name {
@apply max-w-[150px] truncate;
}
.c-create-ledger-form__media-preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-create-ledger-form__error {
@apply text-sm text-red-600;
}
.c-create-ledger-form__actions {
@apply flex items-center gap-4;
}
.c-create-ledger-form__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>
-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>
+113 -14
View File
@@ -8,23 +8,22 @@ 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 }>;
}>; }>;
}>(); }>();
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-ledger-list">
<div class="mb-6 flex items-center justify-between"> <div class="c-ledger-list__header">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 class="c-ledger-list__title">Ledgers</h4>
Ledgers
</h4>
</div> </div>
<ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <ul class="c-ledger-list__grid">
<li <li
v-for="ledger in ledgers" v-for="ledger in ledgers"
:key="ledger.id" :key="ledger.id"
class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700" class="c-ledger-list__item"
> >
<Link <Link
:href=" :href="
@@ -34,40 +33,140 @@ defineProps<{
}) })
" "
> >
<h5 class="text-lg font-semibold"> <h5 class="c-ledger-list__item-name">
{{ ledger.name }} {{ ledger.name }}
</h5> </h5>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p class="c-ledger-list__item-score">
Score: {{ ledger.score }} Score: {{ ledger.score }}
</p> </p>
<!-- Ledger Alignment Badge -->
<div class="c-ledger-list__alignment-wrapper">
<span
v-if="ledger.alignment === 'positive'"
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--positive"
>
Higher is Better
</span>
<span
v-else-if="ledger.alignment === 'negative'"
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--negative"
>
Lower is Better
</span>
<span
v-else
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--neutral"
>
Neutral
</span>
</div>
<!-- Ledger Media Thumbnails --> <!-- Ledger Media Thumbnails -->
<div <div
v-if="ledger.media && ledger.media.length > 0" v-if="ledger.media && ledger.media.length > 0"
class="mt-2 flex flex-wrap gap-1" class="c-ledger-list__media-list"
> >
<div <div
v-for="item in ledger.media" v-for="item in ledger.media"
:key="item.id" :key="item.id"
class="relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600" class="c-ledger-list__media-item"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="size-full object-cover" class="c-ledger-list__media-img"
/> />
<video <video
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
:src="item.url" :src="item.url"
class="size-full object-cover" class="c-ledger-list__media-video"
/> />
</div> </div>
</div> </div>
</Link> </Link>
</li> </li>
</ul> </ul>
<div v-if="ledgers.length === 0" class="mt-4 text-gray-500"> <div v-if="ledgers.length === 0" class="c-ledger-list__empty">
No ledgers found for this dynamic. No ledgers found for this dynamic.
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-ledger-list {
@apply mt-8;
}
.c-ledger-list__header {
@apply mb-6 flex items-center justify-between;
}
.c-ledger-list__title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-ledger-list__grid {
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
}
.c-ledger-list__item {
@apply p-6;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-ledger-list__item-name {
@apply text-lg font-semibold;
color: var(--foreground);
}
.c-ledger-list__item-score {
@apply mt-2 text-sm;
color: var(--muted-foreground);
}
.c-ledger-list__alignment-wrapper {
@apply mt-2 flex items-center;
}
.c-ledger-list__alignment-badge {
@apply rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase;
}
.c-ledger-list__alignment-badge--positive {
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
}
.c-ledger-list__alignment-badge--negative {
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
}
.c-ledger-list__alignment-badge--neutral {
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
}
.c-ledger-list__media-list {
@apply mt-2 flex flex-wrap gap-1;
}
.c-ledger-list__media-item {
@apply relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600;
}
.c-ledger-list__media-img {
@apply size-full object-cover;
}
.c-ledger-list__media-video {
@apply size-full object-cover;
}
.c-ledger-list__empty {
@apply mt-4 text-gray-500;
}
</style>
+158 -32
View File
@@ -6,6 +6,7 @@ import Chat from '@/components/Chat.vue';
const props = defineProps<{ const props = defineProps<{
dynamicId: number; dynamicId: number;
ledgerId: number; ledgerId: number;
ledgerAlignment?: string;
mutations: Array<{ mutations: Array<{
id: number; id: number;
user_id: number; user_id: number;
@@ -44,22 +45,40 @@ function isOwnerUser(userId: number): boolean {
return participant?.pivot?.role === 'owner'; return participant?.pivot?.role === 'owner';
} }
function getAmountClass(amount: number): string {
const alignment = props.ledgerAlignment || 'neutral';
if (alignment === 'positive') {
return amount > 0
? 'c-mutation-list__item-amount--positive'
: 'c-mutation-list__item-amount--negative';
}
if (alignment === 'negative') {
// 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';
}
// Neutral alignment
return 'c-mutation-list__item-amount--neutral';
}
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-mutation-list">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 class="c-mutation-list__title">Mutations</h4>
Mutations <ul class="c-mutation-list__list">
</h4>
<ul class="mt-4 space-y-4">
<li <li
v-for="mutation in mutations" v-for="mutation in mutations"
:key="mutation.id" :key="mutation.id"
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800" class="c-mutation-list__item"
> >
<div class="flex items-start justify-between"> <div class="c-mutation-list__item-header">
<div> <div>
<span class="font-semibold"> <span class="c-mutation-list__item-author">
{{ {{
isOwnerUser(mutation.user_id) isOwnerUser(mutation.user_id)
? 'Added by' ? 'Added by'
@@ -67,13 +86,10 @@ function isOwnerUser(userId: number): boolean {
}} }}
{{ mutation.user.name }} {{ mutation.user.name }}
</span> </span>
<div class="mt-1 flex items-center gap-2"> <div class="c-mutation-list__item-meta">
<span <span
:class="{ :class="getAmountClass(mutation.amount)"
'text-green-500': mutation.amount > 0, class="c-mutation-list__item-amount"
'text-red-500': mutation.amount < 0,
}"
class="text-sm font-bold"
> >
{{ mutation.amount > 0 ? '+' : '' {{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }} }}{{ mutation.amount }}
@@ -82,59 +98,57 @@ function isOwnerUser(userId: number): boolean {
<span <span
v-if="!isOwnerUser(mutation.user_id)" v-if="!isOwnerUser(mutation.user_id)"
:class="{ :class="{
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': 'c-mutation-list__item-status--pending':
mutation.status === 'pending', mutation.status === 'pending',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': 'c-mutation-list__item-status--approved':
mutation.status === 'approved', mutation.status === 'approved',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': 'c-mutation-list__item-status--rejected':
mutation.status === 'rejected', mutation.status === 'rejected',
}" }"
class="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase" class="c-mutation-list__item-status"
> >
{{ mutation.status }} {{ mutation.status }}
</span> </span>
</div> </div>
</div> </div>
<div class="text-xs text-gray-500"> <div class="c-mutation-list__item-time">
{{ new Date(mutation.created_at).toLocaleString() }} {{ new Date(mutation.created_at).toLocaleString() }}
</div> </div>
</div> </div>
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400"> <p class="c-mutation-list__item-desc">
{{ mutation.description }} {{ mutation.description }}
</p> </p>
<!-- Attached Mutation Proof Media --> <!-- Attached Mutation Proof Media -->
<div <div
v-if="mutation.media && mutation.media.length > 0" v-if="mutation.media && mutation.media.length > 0"
class="mt-3 flex flex-wrap gap-2" class="c-mutation-list__media-list"
> >
<div <div
v-for="item in mutation.media" v-for="item in mutation.media"
:key="item.id" :key="item.id"
class="max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700" class="c-mutation-list__media-item"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90" class="c-mutation-list__media-img"
@click=" @click="
emit('open-lightbox', item.url, item.mime_type) emit('open-lightbox', item.url, item.mime_type)
" "
/> />
<div <div
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90" class="c-mutation-list__media-video-wrapper"
@click=" @click="
emit('open-lightbox', item.url, item.mime_type) emit('open-lightbox', item.url, item.mime_type)
" "
> >
<video <video
:src="item.url" :src="item.url"
class="h-auto max-h-[150px] w-full" class="c-mutation-list__media-video"
></video> ></video>
<div <div class="c-mutation-list__media-video-overlay">
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div> </div>
</div> </div>
@@ -144,17 +158,17 @@ function isOwnerUser(userId: number): boolean {
<!-- Owner Approve/Reject Actions --> <!-- Owner Approve/Reject Actions -->
<div <div
v-if="isOwner && mutation.status === 'pending'" v-if="isOwner && mutation.status === 'pending'"
class="mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700" class="c-mutation-list__actions"
> >
<button <button
@click="updateStatus(mutation.id, 'approved')" @click="updateStatus(mutation.id, 'approved')"
class="inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500" class="c-mutation-list__approve-btn"
> >
Approve Approve
</button> </button>
<button <button
@click="updateStatus(mutation.id, 'rejected')" @click="updateStatus(mutation.id, 'rejected')"
class="inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500" class="c-mutation-list__reject-btn"
> >
Reject Reject
</button> </button>
@@ -163,8 +177,120 @@ function isOwnerUser(userId: number): boolean {
<Chat :chat="mutation.chat" /> <Chat :chat="mutation.chat" />
</li> </li>
</ul> </ul>
<div v-if="mutations.length === 0" class="mt-4 text-gray-500"> <div v-if="mutations.length === 0" class="c-mutation-list__empty">
No mutations found for this ledger. No mutations found for this ledger.
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-mutation-list {
@apply mt-8;
}
.c-mutation-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-mutation-list__list {
@apply mt-4 space-y-4;
}
.c-mutation-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-mutation-list__item-header {
@apply flex items-start justify-between;
}
.c-mutation-list__item-author {
@apply font-semibold;
}
.c-mutation-list__item-meta {
@apply mt-1 flex items-center gap-2;
}
.c-mutation-list__item-amount {
@apply text-sm font-bold;
}
.c-mutation-list__item-amount--positive {
@apply text-green-500;
}
.c-mutation-list__item-amount--negative {
@apply text-red-500;
}
.c-mutation-list__item-amount--neutral {
@apply text-gray-500 dark:text-gray-400;
}
.c-mutation-list__item-status {
@apply rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase;
}
.c-mutation-list__item-status--pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.c-mutation-list__item-status--approved {
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
}
.c-mutation-list__item-status--rejected {
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
}
.c-mutation-list__item-time {
@apply text-xs text-gray-500;
}
.c-mutation-list__item-desc {
@apply mt-3 text-sm text-gray-600 dark:text-gray-400;
}
.c-mutation-list__media-list {
@apply mt-3 flex flex-wrap gap-2;
}
.c-mutation-list__media-item {
@apply max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-mutation-list__media-img {
@apply h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video {
@apply h-auto max-h-[150px] w-full;
}
.c-mutation-list__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-mutation-list__actions {
@apply mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700;
}
.c-mutation-list__approve-btn {
@apply inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500;
}
.c-mutation-list__reject-btn {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
}
.c-mutation-list__empty {
@apply mt-4 text-gray-500;
}
</style>
+29 -6
View File
@@ -8,18 +8,41 @@ defineProps<{
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-participants-list">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100"> <h4 class="c-participants-list__title">Participants</h4>
Participants <ul class="c-participants-list__grid">
</h4>
<ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<li <li
v-for="participant in participants" v-for="participant in participants"
:key="participant.id" :key="participant.id"
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800" class="c-participants-list__item"
> >
{{ participant.name }} {{ participant.name }}
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-participants-list {
@apply mt-8;
}
.c-participants-list__title {
@apply text-lg font-medium;
color: var(--foreground);
}
.c-participants-list__grid {
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
}
.c-participants-list__item {
@apply overflow-hidden p-4;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
}
</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>
+245 -16
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head, Link } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
defineOptions({ defineOptions({
@@ -13,35 +12,265 @@ defineOptions({
], ],
}, },
}); });
defineProps<{
unreadEntities: Array<{
id: number;
name: string;
type: 'Dynamic' | 'Ledger';
url: string;
unread_count: number;
context_activities: Array<{
id: number;
content: string;
user: { name: string } | null;
subject: { name: string; url: string } | null;
created_at: string;
url: string;
}>;
new_activities: Array<{
id: number;
content: string;
user: { name: string } | null;
subject: { name: string; url: string } | null;
created_at: string;
url: string;
}>;
}>;
}>();
function formatTime(isoString: string): string {
return new Date(isoString).toLocaleString();
}
</script> </script>
<template> <template>
<Head title="Dashboard" /> <Head title="Dashboard" />
<div class="c-dashboard">
<div class="c-dashboard__container">
<h2 class="c-dashboard__title">Recent Activity</h2>
<div v-if="unreadEntities.length > 0" class="c-dashboard__grid">
<div <div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4" v-for="entity in unreadEntities"
:key="`${entity.type}_${entity.id}`"
class="c-dashboard__card"
> >
<div class="grid auto-rows-min gap-4 md:grid-cols-3"> <div class="c-dashboard__card-header">
<div <div class="c-dashboard__entity-meta">
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border" <span
:class="[
'c-dashboard__badge-type',
entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger',
]"
> >
<PlaceholderPattern /> {{ entity.type }}
</span>
<span class="c-dashboard__unread-count">
{{ entity.unread_count }} New
</span>
</div> </div>
<div <Link
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border" :href="entity.url"
class="c-dashboard__entity-link"
> >
<PlaceholderPattern /> <h3 class="c-dashboard__entity-title">
{{ entity.name }}
</h3>
</Link>
</div> </div>
<div class="c-dashboard__activity-list">
<!-- Context / Read Activities -->
<div <div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border" v-for="activity in entity.context_activities"
:key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read"
> >
<PlaceholderPattern /> <Link :href="activity.url" class="block">
<div class="c-dashboard__activity-meta">
<span class="c-dashboard__activity-user">
{{ activity.user?.name || 'System' }}
</span>
<span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }}
</span>
</div>
<p class="c-dashboard__activity-desc">
{{ activity.content }}
</p>
</Link>
</div>
<!-- Unread Separator Line -->
<div
v-if="entity.context_activities.length > 0"
class="c-dashboard__divider"
>
<span class="c-dashboard__divider-text"
>New Activity Below</span
>
</div>
<!-- New / Unread Activities -->
<div
v-for="activity in entity.new_activities"
:key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread"
>
<Link :href="activity.url" class="block">
<div class="c-dashboard__activity-meta">
<span class="c-dashboard__activity-user">
{{ activity.user?.name || 'System' }}
</span>
<span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }}
</span>
<span class="c-dashboard__new-badge">NEW</span>
</div>
<p class="c-dashboard__activity-desc">
{{ activity.content }}
</p>
</Link>
</div> </div>
</div> </div>
<div </div>
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border" </div>
>
<PlaceholderPattern /> <!-- Empty Caught-Up State -->
<div v-else class="c-dashboard__empty-state">
<div class="c-dashboard__empty-icon">🔒</div>
<p class="c-dashboard__empty-text">
All chambers are currently quiet.
</p>
<p class="c-dashboard__empty-subtext">
Your records are completely up to date. Masterfully done.
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-dashboard {
@apply px-4 py-8 sm:px-6 lg:px-8;
}
.c-dashboard__container {
@apply mx-auto max-w-7xl;
}
.c-dashboard__title {
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
}
.c-dashboard__grid {
@apply grid grid-cols-1 gap-6 md:grid-cols-2;
}
.c-dashboard__card {
@apply flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900;
}
.c-dashboard__card-header {
@apply border-b border-neutral-100 p-6 dark:border-neutral-800/30;
}
.c-dashboard__entity-meta {
@apply mb-2 flex items-center gap-2;
}
.c-dashboard__badge-type {
@apply rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase;
}
.c-dashboard__badge-type--dynamic {
@apply bg-purple-100 text-purple-800 dark:bg-purple-950/20 dark:text-purple-400;
}
.c-dashboard__badge-type--ledger {
@apply bg-indigo-100 text-indigo-800 dark:bg-indigo-950/20 dark:text-indigo-400;
}
.c-dashboard__unread-count {
@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 {
@apply hover:underline focus:outline-none;
}
.c-dashboard__entity-title {
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
}
.c-dashboard__activity-list {
@apply flex-1 space-y-4 p-6;
}
.c-dashboard__activity-item {
@apply rounded-lg border p-4;
}
.c-dashboard__activity-item--read {
@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 {
@apply border-red-200/50 bg-red-50/10 dark:border-red-900/30 dark:bg-red-950/5;
}
.c-dashboard__activity-meta {
@apply mb-1.5 flex items-center gap-2 text-xs;
}
.c-dashboard__activity-user {
@apply font-semibold text-neutral-800 dark:text-neutral-200;
}
.c-dashboard__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-dashboard__new-badge {
@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 {
@apply text-sm text-neutral-600 dark:text-neutral-400;
}
.c-dashboard__divider {
@apply relative my-4 flex items-center justify-center;
}
.c-dashboard__divider-text {
@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 {
content: '';
@apply absolute inset-x-0 h-px bg-neutral-200 dark:bg-neutral-800;
}
.c-dashboard__empty-state {
@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 {
@apply mb-4 text-4xl;
}
.c-dashboard__empty-text {
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
}
.c-dashboard__empty-subtext {
@apply mt-1 max-w-md text-sm text-neutral-500 dark:text-neutral-500;
}
</style>
+77 -22
View File
@@ -26,60 +26,59 @@ function submit() {
<template> <template>
<Head title="Create Dynamic" /> <Head title="Create Dynamic" />
<div class="py-12"> <div class="c-dynamics-create">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="c-dynamics-create__container">
<div <div class="c-dynamics-create__card">
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" <div class="c-dynamics-create__body">
> <h3 class="c-dynamics-create__title">
<div class="p-6 text-gray-900 dark:text-gray-100"> Create a New Dynamic
<h3 class="text-lg font-medium">Create a New Dynamic</h3> </h3>
<form @submit.prevent="submit" class="mt-6 space-y-6"> <form
<div> @submit.prevent="submit"
<label class="c-dynamics-create__form"
for="name" >
class="block text-sm font-medium text-gray-700 dark:text-gray-300" <div class="c-dynamics-create__field">
<label for="name" class="c-dynamics-create__label"
>Name</label >Name</label
> >
<input <input
v-model="form.name" v-model="form.name"
id="name" id="name"
type="text" type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-dynamics-create__input"
/> />
<div <div
v-if="form.errors.name" v-if="form.errors.name"
class="text-sm text-red-600" class="c-dynamics-create__error"
> >
{{ form.errors.name }} {{ form.errors.name }}
</div> </div>
</div> </div>
<div> <div class="c-dynamics-create__field">
<label <label for="rules" class="c-dynamics-create__label"
for="rules"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label >Rules</label
> >
<textarea <textarea
v-model="form.rules" v-model="form.rules"
id="rules" id="rules"
rows="4" rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600" class="c-dynamics-create__textarea"
></textarea> ></textarea>
<div <div
v-if="form.errors.rules" v-if="form.errors.rules"
class="text-sm text-red-600" class="c-dynamics-create__error"
> >
{{ form.errors.rules }} {{ form.errors.rules }}
</div> </div>
</div> </div>
<div class="flex items-center gap-4"> <div class="c-dynamics-create__actions">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300" class="c-dynamics-create__submit-btn"
> >
Create Create
</button> </button>
@@ -90,3 +89,59 @@ function submit() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-dynamics-create {
@apply py-12;
}
.c-dynamics-create__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamics-create__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-dynamics-create__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-dynamics-create__title {
@apply text-lg font-medium;
}
.c-dynamics-create__form {
@apply mt-6 space-y-6;
}
.c-dynamics-create__field {
@apply block;
}
.c-dynamics-create__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-dynamics-create__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-dynamics-create__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-dynamics-create__error {
@apply text-sm text-red-600;
}
.c-dynamics-create__actions {
@apply flex items-center gap-4;
}
.c-dynamics-create__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>
+66 -16
View File
@@ -17,17 +17,15 @@ const breadcrumbs = [
<template> <template>
<Head title="Dynamics" /> <Head title="Dynamics" />
<div class="py-12"> <div class="c-dynamics-index">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="c-dynamics-index__container">
<div <div class="c-dynamics-index__card">
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" <div class="c-dynamics-index__body">
> <div class="c-dynamics-index__header">
<div class="p-6 text-gray-900 dark:text-gray-100"> <h3 class="c-dynamics-index__title">Your Dynamics</h3>
<div class="mb-6 flex items-center justify-between">
<h3 class="text-lg font-medium">Your Dynamics</h3>
<Link <Link
:href="route('dynamics.create')" :href="route('dynamics.create')"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300" class="c-dynamics-index__button"
> >
Create Dynamic Create Dynamic
</Link> </Link>
@@ -35,30 +33,82 @@ const breadcrumbs = [
<div <div
v-if="dynamics.length > 0" v-if="dynamics.length > 0"
class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3" class="c-dynamics-index__grid"
> >
<div <div
v-for="dynamic in dynamics" v-for="dynamic in dynamics"
:key="dynamic.id" :key="dynamic.id"
class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700" class="c-dynamics-index__item"
> >
<Link :href="route('dynamics.show', dynamic.id)"> <Link :href="route('dynamics.show', dynamic.id)">
<h4 class="text-lg font-semibold"> <h4 class="c-dynamics-index__item-title">
{{ dynamic.name }} {{ dynamic.name }}
</h4> </h4>
</Link> </Link>
<p <p class="c-dynamics-index__item-desc">
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
{{ dynamic.rules }} {{ dynamic.rules }}
</p> </p>
</div> </div>
</div> </div>
<div v-else> <div v-else>
<p>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>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-dynamics-index {
@apply py-12;
}
.c-dynamics-index__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamics-index__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-dynamics-index__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-dynamics-index__header {
@apply mb-6 flex items-center justify-between;
}
.c-dynamics-index__title {
@apply text-lg font-medium;
}
.c-dynamics-index__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;
}
.c-dynamics-index__grid {
@apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
}
.c-dynamics-index__item {
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700;
}
.c-dynamics-index__item-title {
@apply text-lg font-semibold;
}
.c-dynamics-index__item-desc {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-dynamics-index__empty {
@apply text-gray-500 dark:text-gray-400;
}
</style>
+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>
+67 -14
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 = [
@@ -37,17 +38,22 @@ const breadcrumbs = [
<template> <template>
<Head :title="dynamic.name" /> <Head :title="dynamic.name" />
<div class="py-12"> <div class="c-dynamic-show">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="c-dynamic-show__container">
<div <div class="c-dynamic-show__card">
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" <div class="c-dynamic-show__body">
> <div class="flex justify-between items-start">
<div class="p-6 text-gray-900 dark:text-gray-100"> <div>
<h3 class="text-lg font-medium">{{ dynamic.name }}</h3> <h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p class="c-dynamic-show__rules">
{{ dynamic.rules }} {{ dynamic.rules }}
</p> </p>
</div> </div>
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
Settings
</InertiaLink>
</div>
</div>
</div> </div>
<!-- Dynamic Chat --> <!-- Dynamic Chat -->
@@ -59,8 +65,55 @@ 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>
<style scoped>
@reference "../../../css/app.css";
.c-dynamic-show {
@apply py-12;
}
.c-dynamic-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamic-show__card {
@apply overflow-hidden;
background-color: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.c-dynamic-show__body {
@apply p-6;
color: var(--foreground);
}
.c-dynamic-show__title {
@apply text-lg font-medium;
}
.c-dynamic-show__rules {
@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>
+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>
+134 -36
View File
@@ -22,6 +22,7 @@ const props = defineProps<{
name: string; name: string;
score: number; score: number;
rules: string; rules: string;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
mutations: Array<{ mutations: Array<{
id: number; id: number;
@@ -147,66 +148,83 @@ function isOwnerUser(userId: number): boolean {
<Head :title="ledger.name" /> <Head :title="ledger.name" />
<!-- Floating Toast Notifications --> <!-- Floating Toast Notifications -->
<div <div class="c-ledger-show__toast-container">
class="pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3"
>
<div <div
v-for="toast in toasts" v-for="toast in toasts"
:key="toast.id" :key="toast.id"
class="pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl" class="c-ledger-show__toast-item"
> >
<span>{{ toast.message }}</span> <span>{{ toast.message }}</span>
<button <button
@click="toasts = toasts.filter((t) => t.id !== toast.id)" @click="toasts = toasts.filter((t) => t.id !== toast.id)"
class="cursor-pointer text-neutral-400 hover:text-white" class="c-ledger-show__toast-close-btn"
> >
</button> </button>
</div> </div>
</div> </div>
<div class="py-12"> <div class="c-ledger-show">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="c-ledger-show__container">
<div <div class="c-ledger-show__card">
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" <div class="c-ledger-show__body">
> <h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
<div class="p-6 text-gray-900 dark:text-gray-100"> <p class="c-ledger-show__score">
<h3 class="text-lg font-medium">{{ ledger.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Score: {{ ledger.score }} Score: {{ ledger.score }}
</p> </p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400"> <p class="c-ledger-show__rules">
{{ ledger.rules }} {{ ledger.rules }}
</p> </p>
<!-- Ledger Alignment Badge / Subtitle -->
<div class="c-ledger-show__alignment-wrapper">
<span
v-if="ledger.alignment === 'positive'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--positive"
>
Positive Alignment A higher score is better.
</span>
<span
v-else-if="ledger.alignment === 'negative'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative"
>
Negative Alignment A lower score is better
(demerits / infractions).
</span>
<span
v-else
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--neutral"
>
Neutral Alignment Scorekeeping neutral.
</span>
</div>
<!-- Ledger Descriptive Media --> <!-- Ledger Descriptive Media -->
<div <div
v-if="ledger.media && ledger.media.length > 0" v-if="ledger.media && ledger.media.length > 0"
class="mt-4 flex flex-wrap gap-3" class="c-ledger-show__media-list"
> >
<div <div
v-for="item in ledger.media" v-for="item in ledger.media"
:key="item.id" :key="item.id"
class="max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700" class="c-ledger-show__media-item"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90" class="c-ledger-show__media-img"
@click="openLightbox(item.url, item.mime_type)" @click="openLightbox(item.url, item.mime_type)"
/> />
<div <div
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90" class="c-ledger-show__media-video-wrapper"
@click="openLightbox(item.url, item.mime_type)" @click="openLightbox(item.url, item.mime_type)"
> >
<video <video
:src="item.url" :src="item.url"
class="h-auto max-h-[200px] w-full" class="c-ledger-show__media-video"
></video> ></video>
<div <div class="c-ledger-show__media-video-overlay">
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div> </div>
</div> </div>
@@ -225,36 +243,116 @@ function isOwnerUser(userId: number): boolean {
:mutations="ledger.mutations" :mutations="ledger.mutations"
:participants="dynamic.participants" :participants="dynamic.participants"
:is-owner="isOwner" :is-owner="isOwner"
:ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox" @open-lightbox="openLightbox"
/> />
</div> </div>
</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="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4" <div class="c-lightbox__content" @click.stop>
@click="closeLightbox"
>
<button
@click="closeLightbox"
class="absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500"
>
</button>
<div class="max-h-full max-w-full" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="max-h-[90vh] max-w-full rounded object-contain shadow-lg" 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="max-h-[90vh] max-w-full rounded shadow-lg" class="c-lightbox__video"
></video> ></video>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-ledger-show__toast-container {
@apply pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3;
}
.c-ledger-show__toast-item {
@apply pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl;
}
.c-ledger-show__toast-close-btn {
@apply cursor-pointer text-neutral-400 hover:text-white;
}
.c-ledger-show {
@apply py-12;
}
.c-ledger-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-ledger-show__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-ledger-show__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-ledger-show__title {
@apply text-lg font-medium;
}
.c-ledger-show__score {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-ledger-show__rules {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-ledger-show__alignment-wrapper {
@apply mt-3 flex items-center;
}
.c-ledger-show__alignment-badge {
@apply rounded px-2.5 py-1 text-xs font-semibold tracking-wide uppercase;
}
.c-ledger-show__alignment-badge--positive {
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
}
.c-ledger-show__alignment-badge--negative {
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
}
.c-ledger-show__alignment-badge--neutral {
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
}
.c-ledger-show__media-list {
@apply mt-4 flex flex-wrap gap-3;
}
.c-ledger-show__media-item {
@apply max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-ledger-show__media-img {
@apply h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-ledger-show__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-ledger-show__media-video {
@apply h-auto max-h-[200px] w-full;
}
.c-ledger-show__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
</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>
+18 -3
View File
@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DynamicController; use App\Http\Controllers\DynamicController;
use App\Http\Controllers\LedgerController; use App\Http\Controllers\LedgerController;
use App\Http\Controllers\MessageController; use App\Http\Controllers\MessageController;
@@ -9,14 +10,28 @@ use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home'); Route::inertia('/', 'Welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard'); Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
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', DynamicController::class);
Route::resource('dynamics.ledgers', LedgerController::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)
);
}
}
+108
View File
@@ -1,6 +1,12 @@
<?php <?php
use App\Models\User; use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Support\Carbon;
test('guests are redirected to the login page', function () { test('guests are redirected to the login page', function () {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
@@ -13,4 +19,106 @@ test('authenticated users can visit the dashboard', function () {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page->component('Dashboard')->has('unreadEntities'));
});
test('visiting dynamic updates the read cursor', function () {
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
$this->actingAs($user);
$service = app(ActivityService::class);
$initialCursor = $service->getCursorReadAt($user, $dynamic);
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Dynamic Show
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
// Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $dynamic);
expect($updatedCursor->gt($initialCursor))->toBeTrue();
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
});
test('visiting ledger updates the read cursor', function () {
$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);
$service = app(ActivityService::class);
$initialCursor = $service->getCursorReadAt($user, $ledger);
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Ledger Show
$this->get(route('dynamics.ledgers.show', [$dynamic->id, $ledger->id]))->assertOk();
// Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $ledger);
expect($updatedCursor->gt($initialCursor))->toBeTrue();
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
});
test('dashboard groups and filters unread entities correctly based on cursor', function () {
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create(['name' => 'Testing Dynamic']);
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
// Create custom Chat so booted has chat available
$dynamic->chat()->create([]);
$this->actingAs($user);
// Create past message (already read)
$pastMsg = Message::create([
'chat_id' => $dynamic->chat->id,
'user_id' => $user->id,
'content' => 'Old message context',
]);
// Artificially advance cursor to after past message
Carbon::setTestNow(Carbon::now()->addMinutes(5));
$service = app(ActivityService::class);
$service->updateCursor($user, $dynamic);
// Create new unread message
Carbon::setTestNow(Carbon::now()->addMinutes(5));
$unreadMsg = Message::create([
'chat_id' => $dynamic->chat->id,
'user_id' => $user->id,
'content' => 'New unread message alert',
]);
// Retrieve unread groupings
$response = $this->get(route('dashboard'));
$response->assertOk();
// Verify unread grouping structure
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->where('unreadEntities.0.name', 'Testing Dynamic')
->where('unreadEntities.0.unread_count', 1)
->has('unreadEntities.0.context_activities', 1) // Should have old message as context
->where('unreadEntities.0.context_activities.0.content', 'Old message context')
->has('unreadEntities.0.new_activities', 1) // Should have unread message
->where('unreadEntities.0.new_activities.0.content', 'New unread message alert')
);
// Now visit the Dynamic, which clears the unread count
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
// Dashboard should now show 0 unread groups (caught up)
$response2 = $this->get(route('dashboard'));
$response2->assertOk();
$response2->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('unreadEntities', 0)
);
Carbon::setTestNow(); // Reset test time
}); });
+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');
});