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.
- 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
- 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.
- 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 ===
+33 -5
View File
@@ -19,18 +19,46 @@ This document outlines the decisions made during the development of the Ledgerrz
## Architectural & Integration Decisions
### 1. Style Architecture Shift to BEM
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.
*Note:* Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
### 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 (or dedicated BEM component files under `/resources/css/components/`).
* **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:
* **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).
### 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`.
### 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
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.
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;
use App\Http\Requests\StoreDynamicRequest;
use App\Http\Requests\UpdateDynamicRequest;
use App\Models\Dynamic;
use App\Services\ActivityService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -44,10 +46,12 @@ class DynamicController extends Controller
/**
* Display the specified resource.
*/
public function show(Dynamic $dynamic)
public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
{
$this->authorize('view', $dynamic);
$activityService->updateCursor($request->user(), $dynamic);
$dynamic->load([
'ledgers.media',
'participants',
@@ -55,8 +59,14 @@ class DynamicController extends Controller
'chat.messages.media'
]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic,
'isOwner' => $isOwner,
]);
}
@@ -65,15 +75,21 @@ class DynamicController extends Controller
*/
public function edit(Dynamic $dynamic)
{
//
$this->authorize('update', $dynamic);
return Inertia::render('Dynamics/Settings', [
'dynamic' => $dynamic,
]);
}
/**
* 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\Models\Dynamic;
use App\Models\Ledger;
use App\Services\ActivityService;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia;
@@ -24,9 +25,13 @@ class LedgerController extends Controller
/**
* 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.
*/
public function show(Request $request, Dynamic $dynamic, Ledger $ledger)
public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService)
{
$this->authorize('view', $ledger);
$activityService->updateCursor($request->user(), $ledger);
$dynamic->load('chat', 'participants');
$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(),
],
'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');
return $dynamic && $this->user()->can('view', $dynamic);
return $dynamic && $this->user()->can('update', $dynamic);
}
/**
@@ -27,6 +27,7 @@ class StoreLedgerRequest extends FormRequest
return [
'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'],
'alignment' => ['required', 'string', 'in:positive,neutral,negative'],
'media' => ['nullable', 'array'],
'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);
}
public function invitations(): HasMany
{
return $this->hasMany(DynamicInvitation::class);
}
public function chat(): MorphOne
{
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',
'rules',
'score',
'alignment',
];
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\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model
{
/** @use HasFactory<MessageFactory> */
@@ -16,6 +18,8 @@ class Message extends Model
'chat_id',
'user_id',
'content',
'subject_id',
'subject_type',
];
public function chat(): BelongsTo
@@ -28,6 +32,11 @@ class Message extends Model
return $this->belongsTo(User::class);
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
+6
View File
@@ -20,6 +20,7 @@ class Mutation extends Model
'amount',
'description',
'status',
'predefined_mutation_id',
];
public function ledger(): BelongsTo
@@ -32,6 +33,11 @@ class Mutation extends Model
return $this->belongsTo(User::class);
}
public function predefinedMutation(): BelongsTo
{
return $this->belongsTo(PredefinedMutation::class);
}
public function chat(): MorphOne
{
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);
}
public function readCursors()
{
return $this->hasMany(ReadCursor::class);
}
/**
* 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
{
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 '';
}
}
+10 -5
View File
@@ -10,22 +10,27 @@ use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
web: __DIR__ . '/../routes/web.php',
commands: __DIR__ . '/../routes/console.php',
channels: __DIR__ . '/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->web(append: [
$middleware
->web(append: [
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
])
->trustProxies(at: [
'172.16.0.0/12',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
fn(Request $request) => $request->is('api/*'),
);
})->create();
+1
View File
@@ -30,6 +30,7 @@ class LedgerFactory extends Factory
]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'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\Mutation;
use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
@@ -16,9 +17,9 @@ class DatabaseSeeder extends Seeder
/**
* 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
$testUser = User::factory()->create([
'name' => 'Test User',
@@ -52,7 +53,9 @@ class DatabaseSeeder extends Seeder
// ----------------------------------------------------
$velvetSanctuary = Dynamic::create([
'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)
@@ -64,29 +67,10 @@ class DatabaseSeeder extends Seeder
$velvetChat = $velvetSanctuary->chat;
// Seed Dynamic Chat Messages
Message::create([
'chat_id' => $velvetChat->id,
'user_id' => $alice->id,
'content' => 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.',
]);
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.',
]);
$activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.');
$activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.');
$activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now.");
$activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.');
// Add Ledgers
$curfewLedger = Ledger::create([
@@ -94,6 +78,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Curfew Compliance',
'rules' => 'Must be in bed and checked in by 11:00 PM.',
'score' => 35,
'alignment' => 'positive',
]);
$cleaningLedger = Ledger::create([
@@ -101,155 +86,49 @@ class DatabaseSeeder extends Seeder
'name' => 'Dungeon Cleaning',
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
'score' => 45,
'alignment' => 'neutral',
]);
$etiquetteLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id,
'name' => 'Protocol & Etiquette',
'rules' => 'Address others by correct titles. -5 per infraction.',
'score' => -15,
'rules' => 'Address others by correct titles. +5 per infraction.',
'score' => 15,
'alignment' => 'negative',
]);
// Seed Curfew Mutations
Mutation::create([
'ledger_id' => $curfewLedger->id,
'user_id' => $bob->id,
'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',
]);
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved');
$activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved');
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved');
// Seed Cleaning Mutations
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'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',
]);
$activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved');
$activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved');
$activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved');
$activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved');
// Seed Pending Mutation with its own Chat Messages!
$pendingMutation = Mutation::create([
'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)
$pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending');
$pendingMutationChat = $pendingMutation->chat;
Message::create([
'chat_id' => $pendingMutationChat->id,
'user_id' => $bob->id,
'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!',
]);
$activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.');
$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.");
$activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!');
// Seed Etiquette Mutations
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'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',
]);
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved');
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved');
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved');
$activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved');
// ----------------------------------------------------
// 3. Seed Dynamic 2: Obsidian Household Agreement
// ----------------------------------------------------
$obsidianHousehold = Dynamic::create([
'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']);
@@ -258,29 +137,10 @@ class DatabaseSeeder extends Seeder
$obsidianChat = $obsidianHousehold->chat;
Message::create([
'chat_id' => $obsidianChat->id,
'user_id' => $alice->id,
'content' => "Who finished the coffee beans and didn't put them on the shopping list?",
]);
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.',
]);
$activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?");
$activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea.");
$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.');
$activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.');
// Add Ledgers
$kitchenLedger = Ledger::create([
@@ -288,6 +148,7 @@ class DatabaseSeeder extends Seeder
'name' => 'Kitchen Chores',
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
'score' => 40,
'alignment' => 'positive',
]);
$coffeeLedger = Ledger::create([
@@ -295,36 +156,15 @@ class DatabaseSeeder extends Seeder
'name' => 'Coffee Machine Maintenance',
'rules' => ' refill beans and descale monthly.',
'score' => 10,
'alignment' => 'neutral',
]);
// Seed Chores Mutations
Mutation::create([
'ledger_id' => $kitchenLedger->id,
'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',
]);
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved');
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved');
// Seed Coffee Mutations
Mutation::create([
'ledger_id' => $coffeeLedger->id,
'user_id' => $testUser->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Descaled and refilled coffee beans',
'status' => 'approved',
]);
$activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved');
});
}
}
+2
View File
@@ -2,6 +2,8 @@
@import 'tw-animate-css';
@import './components.css';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.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);
}
}
+3 -1
View File
@@ -19,7 +19,9 @@ configureEcho({
forceTLS: false,
enabledTransports: ['ws', 'wss'],
});
(window as any).echoConfigured = true;
if (typeof window !== 'undefined') {
(window as any).echoConfigured = true;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
+87 -30
View File
@@ -41,55 +41,48 @@ function submit() {
</script>
<template>
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Add Mutation
</h4>
<form
@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"
<div class="c-add-mutation-form">
<h4 class="c-add-mutation-form__title">Add Mutation</h4>
<form @submit.prevent="submit" class="c-add-mutation-form__form">
<div class="c-add-mutation-form__field">
<label for="amount" class="c-add-mutation-form__label"
>Amount</label
>
<input
v-model="form.amount"
id="amount"
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 }}
</div>
</div>
<div>
<label
for="description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<div class="c-add-mutation-form__field">
<label for="description" class="c-add-mutation-form__label"
>Description</label
>
<textarea
v-model="form.description"
id="description"
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>
<div
v-if="form.errors.description"
class="text-sm text-red-600"
class="c-add-mutation-form__error"
>
{{ form.errors.description }}
</div>
</div>
<!-- Media Uploads for Mutations -->
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<div class="c-add-mutation-form__field">
<label class="c-add-mutation-form__label"
>Attach Proof Media (Photos/Videos)</label
>
<input
@@ -97,24 +90,24 @@ function submit() {
multiple
accept="image/*,video/*"
@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
v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2"
class="c-add-mutation-form__media-preview-list"
>
<div
v-for="(file, index) in form.media"
: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
}}</span>
<button
type="button"
@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>
@@ -122,11 +115,11 @@ function submit() {
</div>
</div>
<div class="flex items-center gap-4">
<div class="c-add-mutation-form__actions">
<button
type="submit"
: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
</button>
@@ -134,3 +127,67 @@ function submit() {
</form>
</div>
</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 />
</main>
</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';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { route } from 'ziggy-js';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes';
@@ -238,6 +239,36 @@ const rightNavItems: NavItem[] = [
</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>
<DropdownMenuTrigger :as-child="true">
<Button
+1 -21
View File
@@ -7,26 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
<AppLogoIcon class="app-logo__icon" />
</div>
<div class="app-logo__text-container">
<span class="app-logo__text">Laravel Starter Kit</span>
<span class="app-logo__text">Ledgerrz</span>
</div>
</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">
import { Link } from '@inertiajs/vue3';
import { Link, router } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
import { onMounted, onUnmounted } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
@@ -13,11 +14,30 @@ import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as dynamics } from '@/routes/dynamics';
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[] = [
{
title: 'Dashboard',
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
];
const footerNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: FolderGit2,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
// {
// title: 'Repository',
// href: 'https://github.com/laravel/vue-starter-kit',
// icon: FolderGit2,
// },
// {
// title: 'Documentation',
// href: 'https://laravel.com/docs/starter-kits#vue',
// icon: BookOpen,
// },
];
</script>
@@ -23,15 +23,3 @@ withDefaults(
</div>
</header>
</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>
</div>
</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">
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 { Paperclip } from '@lucide/vue';
import { ref } from 'vue';
import { route } from 'ziggy-js';
import { Paperclip, Info } from '@lucide/vue';
const props = defineProps<{
chat: {
id: number;
messages: Array<{
id: number;
user: { name: string };
user: { id: number; name: string };
content: string;
created_at: string;
media?: Array<{
@@ -52,7 +52,6 @@ useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
@@ -64,11 +63,16 @@ function removeFile(index: number) {
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() {
form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
@@ -100,8 +104,20 @@ function closeLightbox() {
<div
v-for="message in chat.messages"
: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">
<span class="c-chat__message-author">{{
message.user.name
@@ -144,6 +160,25 @@ function closeLightbox() {
</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 v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet.
@@ -157,7 +192,8 @@ function closeLightbox() {
id="content"
rows="3"
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>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
@@ -215,165 +251,22 @@ function closeLightbox() {
</form>
<!-- Gorgeous Dark Lightbox Modal -->
<div
v-if="activeLightboxUrl"
class="c-chat__lightbox"
@click="closeLightbox"
>
<button @click="closeLightbox" class="c-chat__lightbox-close">
</button>
<div class="c-chat__lightbox-content" @click.stop>
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
<button @click="closeLightbox" class="c-lightbox__close"></button>
<div class="c-lightbox__content" @click.stop>
<img
v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl"
class="c-chat__lightbox-image"
class="c-lightbox__image"
/>
<video
v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl"
controls
autoplay
class="c-chat__lightbox-video"
class="c-lightbox__video"
></video>
</div>
</div>
</div>
</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({
name: '',
rules: '',
alignment: 'neutral',
media: [] as File[],
});
@@ -34,58 +35,83 @@ function submit() {
</script>
<template>
<div class="mt-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Ledger</h3>
<div class="c-create-ledger-form">
<div class="c-create-ledger-form__card">
<div class="c-create-ledger-form__body">
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
<form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<form
@submit.prevent="submit"
class="c-create-ledger-form__form"
>
<div class="c-create-ledger-form__field">
<label for="name" class="c-create-ledger-form__label"
>Name</label
>
<input
v-model="form.name"
id="name"
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
v-if="form.errors.name"
class="text-sm text-red-600"
class="c-create-ledger-form__error"
>
{{ form.errors.name }}
</div>
</div>
<div>
<label
for="rules"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<div class="c-create-ledger-form__field">
<label for="rules" class="c-create-ledger-form__label"
>Rules</label
>
<textarea
v-model="form.rules"
id="rules"
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>
<div
v-if="form.errors.rules"
class="text-sm text-red-600"
class="c-create-ledger-form__error"
>
{{ form.errors.rules }}
</div>
</div>
<!-- Media Uploads for Ledgers -->
<div>
<div class="c-create-ledger-form__field">
<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
>
<input
@@ -93,24 +119,25 @@ function submit() {
multiple
accept="image/*,video/*"
@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
v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2"
class="c-create-ledger-form__media-preview-list"
>
<div
v-for="(file, index) in form.media"
: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
type="button"
@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>
@@ -118,11 +145,11 @@ function submit() {
</div>
</div>
<div class="flex items-center gap-4">
<div class="c-create-ledger-form__actions">
<button
type="submit"
: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
</button>
@@ -132,3 +159,93 @@ function submit() {
</div>
</div>
</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>
</header>
</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>
</div>
</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;
name: string;
score: number;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>;
}>;
}>();
</script>
<template>
<div class="mt-8">
<div class="mb-6 flex items-center justify-between">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Ledgers
</h4>
<div class="c-ledger-list">
<div class="c-ledger-list__header">
<h4 class="c-ledger-list__title">Ledgers</h4>
</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
v-for="ledger in ledgers"
: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
:href="
@@ -34,40 +33,140 @@ defineProps<{
})
"
>
<h5 class="text-lg font-semibold">
<h5 class="c-ledger-list__item-name">
{{ ledger.name }}
</h5>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<p class="c-ledger-list__item-score">
Score: {{ ledger.score }}
</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 -->
<div
v-if="ledger.media && ledger.media.length > 0"
class="mt-2 flex flex-wrap gap-1"
class="c-ledger-list__media-list"
>
<div
v-for="item in ledger.media"
: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
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
class="size-full object-cover"
class="c-ledger-list__media-img"
/>
<video
v-else-if="item.mime_type.startsWith('video/')"
:src="item.url"
class="size-full object-cover"
class="c-ledger-list__media-video"
/>
</div>
</div>
</Link>
</li>
</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.
</div>
</div>
</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<{
dynamicId: number;
ledgerId: number;
ledgerAlignment?: string;
mutations: Array<{
id: number;
user_id: number;
@@ -44,22 +45,40 @@ function isOwnerUser(userId: number): boolean {
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>
<template>
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Mutations
</h4>
<ul class="mt-4 space-y-4">
<div class="c-mutation-list">
<h4 class="c-mutation-list__title">Mutations</h4>
<ul class="c-mutation-list__list">
<li
v-for="mutation in mutations"
: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>
<span class="font-semibold">
<span class="c-mutation-list__item-author">
{{
isOwnerUser(mutation.user_id)
? 'Added by'
@@ -67,13 +86,10 @@ function isOwnerUser(userId: number): boolean {
}}
{{ mutation.user.name }}
</span>
<div class="mt-1 flex items-center gap-2">
<div class="c-mutation-list__item-meta">
<span
:class="{
'text-green-500': mutation.amount > 0,
'text-red-500': mutation.amount < 0,
}"
class="text-sm font-bold"
:class="getAmountClass(mutation.amount)"
class="c-mutation-list__item-amount"
>
{{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }}
@@ -82,59 +98,57 @@ function isOwnerUser(userId: number): boolean {
<span
v-if="!isOwnerUser(mutation.user_id)"
: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',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400':
'c-mutation-list__item-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',
}"
class="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
class="c-mutation-list__item-status"
>
{{ mutation.status }}
</span>
</div>
</div>
<div class="text-xs text-gray-500">
<div class="c-mutation-list__item-time">
{{ new Date(mutation.created_at).toLocaleString() }}
</div>
</div>
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
<p class="c-mutation-list__item-desc">
{{ mutation.description }}
</p>
<!-- Attached Mutation Proof Media -->
<div
v-if="mutation.media && mutation.media.length > 0"
class="mt-3 flex flex-wrap gap-2"
class="c-mutation-list__media-list"
>
<div
v-for="item in mutation.media"
: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
v-if="item.mime_type.startsWith('image/')"
: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="
emit('open-lightbox', item.url, item.mime_type)
"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
class="c-mutation-list__media-video-wrapper"
@click="
emit('open-lightbox', item.url, item.mime_type)
"
>
<video
:src="item.url"
class="h-auto max-h-[150px] w-full"
class="c-mutation-list__media-video"
></video>
<div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
<div class="c-mutation-list__media-video-overlay">
</div>
</div>
@@ -144,17 +158,17 @@ function isOwnerUser(userId: number): boolean {
<!-- Owner Approve/Reject Actions -->
<div
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
@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
</button>
<button
@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
</button>
@@ -163,8 +177,120 @@ function isOwnerUser(userId: number): boolean {
<Chat :chat="mutation.chat" />
</li>
</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.
</div>
</div>
</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>
<template>
<div class="mt-8">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Participants
</h4>
<ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<div class="c-participants-list">
<h4 class="c-participants-list__title">Participants</h4>
<ul class="c-participants-list__grid">
<li
v-for="participant in participants"
: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 }}
</li>
</ul>
</div>
</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>
</div>
</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 />
</Link>
</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>
</div>
</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>
</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">
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Head, Link } from '@inertiajs/vue3';
import { dashboard } from '@/routes';
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>
<template>
<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
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="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
<div class="c-dashboard__card-header">
<div class="c-dashboard__entity-meta">
<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
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
<Link
:href="entity.url"
class="c-dashboard__entity-link"
>
<PlaceholderPattern />
<h3 class="c-dashboard__entity-title">
{{ entity.name }}
</h3>
</Link>
</div>
<div class="c-dashboard__activity-list">
<!-- Context / Read Activities -->
<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
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
</div>
<!-- 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>
</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>
<Head title="Create Dynamic" />
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Dynamic</h3>
<div class="c-dynamics-create">
<div class="c-dynamics-create__container">
<div class="c-dynamics-create__card">
<div class="c-dynamics-create__body">
<h3 class="c-dynamics-create__title">
Create a New Dynamic
</h3>
<form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<label
for="name"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<form
@submit.prevent="submit"
class="c-dynamics-create__form"
>
<div class="c-dynamics-create__field">
<label for="name" class="c-dynamics-create__label"
>Name</label
>
<input
v-model="form.name"
id="name"
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
v-if="form.errors.name"
class="text-sm text-red-600"
class="c-dynamics-create__error"
>
{{ form.errors.name }}
</div>
</div>
<div>
<label
for="rules"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<div class="c-dynamics-create__field">
<label for="rules" class="c-dynamics-create__label"
>Rules</label
>
<textarea
v-model="form.rules"
id="rules"
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>
<div
v-if="form.errors.rules"
class="text-sm text-red-600"
class="c-dynamics-create__error"
>
{{ form.errors.rules }}
</div>
</div>
<div class="flex items-center gap-4">
<div class="c-dynamics-create__actions">
<button
type="submit"
: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
</button>
@@ -90,3 +89,59 @@ function submit() {
</div>
</div>
</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>
<Head title="Dynamics" />
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<div class="mb-6 flex items-center justify-between">
<h3 class="text-lg font-medium">Your Dynamics</h3>
<div class="c-dynamics-index">
<div class="c-dynamics-index__container">
<div class="c-dynamics-index__card">
<div class="c-dynamics-index__body">
<div class="c-dynamics-index__header">
<h3 class="c-dynamics-index__title">Your Dynamics</h3>
<Link
: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
</Link>
@@ -35,30 +33,82 @@ const breadcrumbs = [
<div
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
v-for="dynamic in dynamics"
: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)">
<h4 class="text-lg font-semibold">
<h4 class="c-dynamics-index__item-title">
{{ dynamic.name }}
</h4>
</Link>
<p
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
<p class="c-dynamics-index__item-desc">
{{ dynamic.rules }}
</p>
</div>
</div>
<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>
</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">
import { Head } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
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 LedgerList from '@/components/LedgerList.vue';
import { Head, Link as InertiaLink } from '@inertiajs/vue3';
import { route } from 'ziggy-js';
const props = defineProps<{
dynamic: {
@@ -17,9 +16,11 @@ const props = defineProps<{
id: number;
name: string;
score: number;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>;
}>;
};
isOwner: boolean;
}>();
const breadcrumbs = [
@@ -37,17 +38,22 @@ const breadcrumbs = [
<template>
<Head :title="dynamic.name" />
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">{{ dynamic.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<div class="c-dynamic-show">
<div class="c-dynamic-show__container">
<div class="c-dynamic-show__card">
<div class="c-dynamic-show__body">
<div class="flex justify-between items-start">
<div>
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
<p class="c-dynamic-show__rules">
{{ dynamic.rules }}
</p>
</div>
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
Settings
</InertiaLink>
</div>
</div>
</div>
<!-- Dynamic Chat -->
@@ -59,8 +65,55 @@ const breadcrumbs = [
<!-- Ledgers List Component -->
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
<!-- Create Ledger Form Component -->
<CreateLedgerForm :dynamic-id="dynamic.id" />
<div v-if="isOwner" class="mt-8 flex gap-4">
<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>
</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;
score: number;
rules: string;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>;
mutations: Array<{
id: number;
@@ -147,66 +148,83 @@ function isOwnerUser(userId: number): boolean {
<Head :title="ledger.name" />
<!-- Floating Toast Notifications -->
<div
class="pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3"
>
<div class="c-ledger-show__toast-container">
<div
v-for="toast in toasts"
: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>
<button
@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>
</div>
</div>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">{{ ledger.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<div class="c-ledger-show">
<div class="c-ledger-show__container">
<div class="c-ledger-show__card">
<div class="c-ledger-show__body">
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
<p class="c-ledger-show__score">
Score: {{ ledger.score }}
</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<p class="c-ledger-show__rules">
{{ ledger.rules }}
</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 -->
<div
v-if="ledger.media && ledger.media.length > 0"
class="mt-4 flex flex-wrap gap-3"
class="c-ledger-show__media-list"
>
<div
v-for="item in ledger.media"
: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
v-if="item.mime_type.startsWith('image/')"
: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)"
/>
<div
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)"
>
<video
:src="item.url"
class="h-auto max-h-[200px] w-full"
class="c-ledger-show__media-video"
></video>
<div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
<div class="c-ledger-show__media-video-overlay">
</div>
</div>
@@ -225,36 +243,116 @@ function isOwnerUser(userId: number): boolean {
:mutations="ledger.mutations"
:participants="dynamic.participants"
:is-owner="isOwner"
:ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox"
/>
</div>
</div>
<!-- Lightbox Modal -->
<div
v-if="activeLightboxUrl"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4"
@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>
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
<button @click="closeLightbox" class="c-lightbox__close"></button>
<div class="c-lightbox__content" @click.stop>
<img
v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl"
class="max-h-[90vh] max-w-full rounded object-contain shadow-lg"
class="c-lightbox__image"
/>
<video
v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl"
controls
autoplay
class="max-h-[90vh] max-w-full rounded shadow-lg"
class="c-lightbox__video"
></video>
</div>
</div>
</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
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DynamicController;
use App\Http\Controllers\LedgerController;
use App\Http\Controllers\MessageController;
@@ -9,14 +10,28 @@ use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home');
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::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::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept'])
->middleware(['auth', 'signed'])
->name('dynamics.invitations.accept');
\Illuminate\Support\Facades\Broadcast::routes();
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
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 () {
$response = $this->get(route('dashboard'));
@@ -13,4 +19,106 @@ test('authenticated users can visit the dashboard', function () {
$response = $this->get(route('dashboard'));
$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');
});