Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a92334125 | ||
|
|
93e8f035e6 | ||
|
|
3c414e1ef1 | ||
|
|
b9d2988e8c | ||
|
|
69762667bc | ||
|
|
f493be6512 | ||
|
|
eeb3bf85a9 | ||
|
|
ef00a73daa | ||
|
|
ae6a4304f9 | ||
|
|
d01091d5a6 | ||
|
|
8f9c4c9d1f | ||
|
|
5b1a634d80 | ||
|
|
379543a351 | ||
|
|
06cd53fe91 | ||
|
|
5404b1a535 | ||
|
|
cb01acd6e0 | ||
|
|
4e9e6dce2a | ||
|
|
d44bcf6fda | ||
|
|
9a9a901d46 |
@@ -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.
|
||||
|
||||
+33
-5
@@ -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.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
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;
|
||||
|
||||
use App\Services\ActivityService;
|
||||
|
||||
class DynamicController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
@@ -46,11 +46,11 @@ class DynamicController extends Controller
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Dynamic $dynamic, ActivityService $activityService)
|
||||
public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
|
||||
{
|
||||
$this->authorize('view', $dynamic);
|
||||
|
||||
$activityService->updateCursor(auth()->user(), $dynamic);
|
||||
$activityService->updateCursor($request->user(), $dynamic);
|
||||
|
||||
$dynamic->load([
|
||||
'ledgers.media',
|
||||
@@ -59,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -69,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!');
|
||||
}
|
||||
}
|
||||
@@ -25,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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Mutation;
|
||||
use App\Models\Message;
|
||||
use App\Models\ReadCursor;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ActivityService
|
||||
{
|
||||
@@ -44,121 +45,54 @@ class ActivityService
|
||||
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all activities for a Dynamic.
|
||||
*/
|
||||
public function getDynamicActivities(Dynamic $dynamic): array
|
||||
public function createMessage($chat, $user, $content, $subject = null)
|
||||
{
|
||||
$activities = [];
|
||||
$message = $chat->messages()->create([
|
||||
'user_id' => $user ? $user->id : null,
|
||||
'content' => $content,
|
||||
'subject_id' => $subject ? $subject->id : null,
|
||||
'subject_type' => $subject ? get_class($subject) : null,
|
||||
]);
|
||||
|
||||
// 1. Chat Messages
|
||||
if ($dynamic->chat) {
|
||||
$messages = Message::where('chat_id', $dynamic->chat->id)
|
||||
->with('user')
|
||||
->get();
|
||||
|
||||
foreach ($messages as $msg) {
|
||||
$activities[] = [
|
||||
'id' => "dynamic_msg_{$msg->id}",
|
||||
'type' => 'message',
|
||||
'description' => $msg->content,
|
||||
'user' => [
|
||||
'name' => $msg->user ? $msg->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $msg->created_at,
|
||||
];
|
||||
}
|
||||
return $message;
|
||||
}
|
||||
|
||||
// 2. Ledgers Created
|
||||
$ledgers = Ledger::where('dynamic_id', $dynamic->id)->get();
|
||||
foreach ($ledgers as $ledger) {
|
||||
$activities[] = [
|
||||
'id' => "ledger_created_{$ledger->id}",
|
||||
'type' => 'ledger_created',
|
||||
'description' => "New Ledger '{$ledger->name}' was created.",
|
||||
'user' => [
|
||||
'name' => 'System',
|
||||
],
|
||||
'created_at' => $ledger->created_at,
|
||||
];
|
||||
}
|
||||
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,
|
||||
]);
|
||||
|
||||
// Sort activities chronologically ascending
|
||||
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
|
||||
|
||||
return $activities;
|
||||
return $mutation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all activities for a Ledger.
|
||||
* Retrieve all activities for a given entity.
|
||||
*/
|
||||
public function getLedgerActivities(Ledger $ledger): array
|
||||
public function getActivitiesForEntity($entity): array
|
||||
{
|
||||
$activities = [];
|
||||
if ($entity instanceof Dynamic) {
|
||||
$chatId = $entity->chat->id;
|
||||
} elseif ($entity instanceof Ledger) {
|
||||
$chatId = $entity->dynamic->chat->id;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 1. Mutations (Creation and Status updates)
|
||||
$mutations = Mutation::where('ledger_id', $ledger->id)
|
||||
->with('user')
|
||||
$messages = Message::where('chat_id', $chatId)
|
||||
->with(['user', 'subject'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
foreach ($mutations as $mutation) {
|
||||
// Log creation of mutation
|
||||
$verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry');
|
||||
$activities[] = [
|
||||
'id' => "mutation_created_{$mutation->id}",
|
||||
'type' => 'mutation_created',
|
||||
'description' => "{$verb} ({$mutation->amount}): \"{$mutation->description}\"",
|
||||
'user' => [
|
||||
'name' => $mutation->user ? $mutation->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $mutation->created_at,
|
||||
];
|
||||
|
||||
// Log status approval/rejection update if different from creation
|
||||
if ($mutation->status !== 'pending' && $mutation->updated_at->gt($mutation->created_at->addSeconds(2))) {
|
||||
$activities[] = [
|
||||
'id' => "mutation_updated_{$mutation->id}",
|
||||
'type' => 'mutation_updated',
|
||||
'description' => "Entry '{$mutation->description}' was " . strtoupper($mutation->status),
|
||||
'user' => [
|
||||
'name' => 'System',
|
||||
],
|
||||
'created_at' => $mutation->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Mutation Comments
|
||||
if ($mutations->isNotEmpty()) {
|
||||
$comments = Message::whereHas('chat', function ($q) use ($mutations) {
|
||||
$q->where('chatable_type', Mutation::class)
|
||||
->whereIn('chatable_id', $mutations->pluck('id'));
|
||||
})
|
||||
->with(['user', 'chat.chatable'])
|
||||
->get();
|
||||
|
||||
foreach ($comments as $comment) {
|
||||
/** @var Mutation|null $mutationEntity */
|
||||
$mutationEntity = $comment->chat->chatable;
|
||||
$context = $mutationEntity ? " on \"{$mutationEntity->description}\"" : "";
|
||||
|
||||
$activities[] = [
|
||||
'id' => "comment_{$comment->id}",
|
||||
'type' => 'comment',
|
||||
'description' => "Commented{$context}: \"{$comment->content}\"",
|
||||
'user' => [
|
||||
'name' => $comment->user ? $comment->user->name : 'Unknown User',
|
||||
],
|
||||
'created_at' => $comment->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Sort activities chronologically ascending
|
||||
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
|
||||
|
||||
return $activities;
|
||||
return $messages->map(function ($message) {
|
||||
$messageData = $message->toArray();
|
||||
$messageData['url'] = $this->getUrlForMessage($message);
|
||||
return $messageData;
|
||||
})->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,27 +101,15 @@ class ActivityService
|
||||
public function getUnreadEntitiesGrouped(User $user): array
|
||||
{
|
||||
$groupedEntities = [];
|
||||
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
|
||||
|
||||
// 1. Get all participating Dynamics
|
||||
$dynamics = $user->dynamics()->get();
|
||||
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
|
||||
|
||||
foreach ($dynamics as $dynamic) {
|
||||
$readAt = $this->getCursorReadAt($user, $dynamic);
|
||||
$activities = $this->getDynamicActivities($dynamic);
|
||||
foreach ($entities as $entity) {
|
||||
$readAt = $this->getCursorReadAt($user, $entity);
|
||||
$activities = $this->getActivitiesForEntity($entity);
|
||||
|
||||
$this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities);
|
||||
}
|
||||
|
||||
// 2. Get all Ledgers under those Dynamics
|
||||
if ($dynamics->isNotEmpty()) {
|
||||
$ledgers = Ledger::whereIn('dynamic_id', $dynamics->pluck('id'))->get();
|
||||
|
||||
foreach ($ledgers as $ledger) {
|
||||
$readAt = $this->getCursorReadAt($user, $ledger);
|
||||
$activities = $this->getLedgerActivities($ledger);
|
||||
|
||||
$this->partitionActivities($activities, $readAt, $ledger, 'Ledger', route('dynamics.ledgers.show', [$ledger->dynamic_id, $ledger->id]), $groupedEntities);
|
||||
}
|
||||
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
|
||||
}
|
||||
|
||||
return $groupedEntities;
|
||||
@@ -202,7 +124,7 @@ class ActivityService
|
||||
$unread = [];
|
||||
|
||||
foreach ($activities as $act) {
|
||||
if ($act['created_at']->gt($readAt)) {
|
||||
if (Carbon::parse($act['created_at'])->gt($readAt)) {
|
||||
$unread[] = $act;
|
||||
} else {
|
||||
$alreadyRead[] = $act;
|
||||
@@ -210,24 +132,43 @@ class ActivityService
|
||||
}
|
||||
|
||||
if (!empty($unread)) {
|
||||
// We have unread activity! Let's pull the last two read items as context
|
||||
$context = array_slice($alreadyRead, -2);
|
||||
|
||||
// Format timestamps for serializing to frontend
|
||||
$formatActivity = function ($act) {
|
||||
$act['created_at'] = $act['created_at']->toIso8601String();
|
||||
return $act;
|
||||
};
|
||||
$context = array_slice($alreadyRead, 0, 2);
|
||||
|
||||
$groupedEntities[] = [
|
||||
'id' => $entity->id,
|
||||
'name' => $entity->name,
|
||||
'type' => $type,
|
||||
'type' => Str::afterLast($type, '\\'),
|
||||
'url' => $url,
|
||||
'unread_count' => count($unread),
|
||||
'context_activities' => array_map($formatActivity, $context),
|
||||
'new_activities' => array_map($formatActivity, $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
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+29
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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([
|
||||
@@ -114,145 +98,37 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
// 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' => $alice->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' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 5,
|
||||
'description' => 'Interrupted Domina Alice during daily instructions',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 10,
|
||||
'description' => 'Forgot correct posture during morning roll call',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $alice->id,
|
||||
'type' => 'penalty',
|
||||
'amount' => 5,
|
||||
'description' => 'Spoke out of turn in general chat',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
Mutation::create([
|
||||
'ledger_id' => $etiquetteLedger->id,
|
||||
'user_id' => $bob->id,
|
||||
'type' => 'reward',
|
||||
'amount' => -5,
|
||||
'description' => 'Excellent reciting of the house codes',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
|
||||
$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']);
|
||||
@@ -261,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([
|
||||
@@ -303,33 +160,11 @@ class DatabaseSeeder extends Seeder
|
||||
]);
|
||||
|
||||
// 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,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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* 2. InputError Component */
|
||||
.c-input-error__message {
|
||||
@apply text-sm;
|
||||
color: var(--destructive);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* 5. UserInfo Component */
|
||||
.user-info__avatar {
|
||||
@apply h-8 w-8 overflow-hidden;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.user-info__details {
|
||||
@apply grid flex-1 text-left text-sm leading-tight;
|
||||
|
||||
.user-info__name {
|
||||
@apply truncate font-medium;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.user-info__email {
|
||||
@apply truncate text-xs;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -19,7 +19,7 @@ configureEcho({
|
||||
forceTLS: false,
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
});
|
||||
if(window){
|
||||
if (typeof window !== 'undefined') {
|
||||
(window as any).echoConfigured = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,17 +42,10 @@ function submit() {
|
||||
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
<label for="amount" class="c-add-mutation-form__label"
|
||||
>Amount</label
|
||||
>
|
||||
<input
|
||||
@@ -61,15 +54,16 @@ function submit() {
|
||||
type="number"
|
||||
class="c-add-mutation-form__input"
|
||||
/>
|
||||
<div v-if="form.errors.amount" class="c-add-mutation-form__error">
|
||||
<div
|
||||
v-if="form.errors.amount"
|
||||
class="c-add-mutation-form__error"
|
||||
>
|
||||
{{ form.errors.amount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c-add-mutation-form__field">
|
||||
<label
|
||||
for="description"
|
||||
class="c-add-mutation-form__label"
|
||||
<label for="description" class="c-add-mutation-form__label"
|
||||
>Description</label
|
||||
>
|
||||
<textarea
|
||||
@@ -88,8 +82,7 @@ function submit() {
|
||||
|
||||
<!-- Media Uploads for Mutations -->
|
||||
<div class="c-add-mutation-form__field">
|
||||
<label
|
||||
class="c-add-mutation-form__label"
|
||||
<label class="c-add-mutation-form__label"
|
||||
>Attach Proof Media (Photos/Videos)</label
|
||||
>
|
||||
<input
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -239,7 +240,7 @@ const rightNavItems: NavItem[] = [
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="page.props.unreadNotificationsCount > 0"
|
||||
v-if="(page.props as any).unreadNotificationsCount > 0"
|
||||
class="relative"
|
||||
>
|
||||
<Link
|
||||
@@ -260,7 +261,9 @@ const rightNavItems: NavItem[] = [
|
||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||
/>
|
||||
</svg>
|
||||
<span class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
|
||||
>
|
||||
{{ page.props.unreadNotificationsCount }}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -40,11 +40,12 @@ function submit() {
|
||||
<div class="c-create-ledger-form__body">
|
||||
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
|
||||
|
||||
<form @submit.prevent="submit" class="c-create-ledger-form__form">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="c-create-ledger-form__form"
|
||||
>
|
||||
<div class="c-create-ledger-form__field">
|
||||
<label
|
||||
for="name"
|
||||
class="c-create-ledger-form__label"
|
||||
<label for="name" class="c-create-ledger-form__label"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
@@ -62,9 +63,7 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<div class="c-create-ledger-form__field">
|
||||
<label
|
||||
for="rules"
|
||||
class="c-create-ledger-form__label"
|
||||
<label for="rules" class="c-create-ledger-form__label"
|
||||
>Rules</label
|
||||
>
|
||||
<textarea
|
||||
@@ -92,9 +91,15 @@ function submit() {
|
||||
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>
|
||||
<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"
|
||||
@@ -106,8 +111,7 @@ function submit() {
|
||||
|
||||
<!-- Media Uploads for Ledgers -->
|
||||
<div class="c-create-ledger-form__field">
|
||||
<label
|
||||
class="c-create-ledger-form__label"
|
||||
<label class="c-create-ledger-form__label"
|
||||
>Attach Cover/Rules Media</label
|
||||
>
|
||||
<input
|
||||
@@ -126,9 +130,10 @@ function submit() {
|
||||
:key="index"
|
||||
class="c-create-ledger-form__media-preview-item"
|
||||
>
|
||||
<span class="c-create-ledger-form__media-preview-name">{{
|
||||
file.name
|
||||
}}</span>
|
||||
<span
|
||||
class="c-create-ledger-form__media-preview-name"
|
||||
>{{ file.name }}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeLedgerFile(index)"
|
||||
@@ -163,11 +168,15 @@ function submit() {
|
||||
}
|
||||
|
||||
.c-create-ledger-form__card {
|
||||
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
@apply overflow-hidden;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__body {
|
||||
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||
@apply p-6;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__title {
|
||||
@@ -183,19 +192,29 @@ function submit() {
|
||||
}
|
||||
|
||||
.c-create-ledger-form__label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||
@apply block text-sm font-medium;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__input {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__textarea {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__select {
|
||||
@apply mt-1 block w-full rounded-md border border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||
border-color: var(--border);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-create-ledger-form__file-input {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,9 +17,7 @@ defineProps<{
|
||||
<template>
|
||||
<div class="c-ledger-list">
|
||||
<div class="c-ledger-list__header">
|
||||
<h4 class="c-ledger-list__title">
|
||||
Ledgers
|
||||
</h4>
|
||||
<h4 class="c-ledger-list__title">Ledgers</h4>
|
||||
</div>
|
||||
<ul class="c-ledger-list__grid">
|
||||
<li
|
||||
@@ -107,7 +105,8 @@ defineProps<{
|
||||
}
|
||||
|
||||
.c-ledger-list__title {
|
||||
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
|
||||
@apply text-lg font-medium;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-ledger-list__grid {
|
||||
@@ -115,15 +114,20 @@ defineProps<{
|
||||
}
|
||||
|
||||
.c-ledger-list__item {
|
||||
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700;
|
||||
@apply p-6;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.c-ledger-list__item-name {
|
||||
@apply text-lg font-semibold;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-ledger-list__item-score {
|
||||
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||
@apply mt-2 text-sm;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.c-ledger-list__alignment-wrapper {
|
||||
@@ -131,19 +135,19 @@ defineProps<{
|
||||
}
|
||||
|
||||
.c-ledger-list__alignment-badge {
|
||||
@apply text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded;
|
||||
@apply rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase;
|
||||
}
|
||||
|
||||
.c-ledger-list__alignment-badge--positive {
|
||||
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20;
|
||||
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
|
||||
}
|
||||
|
||||
.c-ledger-list__alignment-badge--negative {
|
||||
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20;
|
||||
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
|
||||
}
|
||||
|
||||
.c-ledger-list__alignment-badge--neutral {
|
||||
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20;
|
||||
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.c-ledger-list__media-list {
|
||||
|
||||
@@ -50,12 +50,16 @@ 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';
|
||||
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';
|
||||
return amount < 0
|
||||
? 'c-mutation-list__item-amount--positive'
|
||||
: 'c-mutation-list__item-amount--negative';
|
||||
}
|
||||
|
||||
// Neutral alignment
|
||||
@@ -65,9 +69,7 @@ function getAmountClass(amount: number): string {
|
||||
|
||||
<template>
|
||||
<div class="c-mutation-list">
|
||||
<h4 class="c-mutation-list__title">
|
||||
Mutations
|
||||
</h4>
|
||||
<h4 class="c-mutation-list__title">Mutations</h4>
|
||||
<ul class="c-mutation-list__list">
|
||||
<li
|
||||
v-for="mutation in mutations"
|
||||
|
||||
@@ -9,9 +9,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="c-participants-list">
|
||||
<h4 class="c-participants-list__title">
|
||||
Participants
|
||||
</h4>
|
||||
<h4 class="c-participants-list__title">Participants</h4>
|
||||
<ul class="c-participants-list__grid">
|
||||
<li
|
||||
v-for="participant in participants"
|
||||
@@ -32,7 +30,8 @@ defineProps<{
|
||||
}
|
||||
|
||||
.c-participants-list__title {
|
||||
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
|
||||
@apply text-lg font-medium;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-participants-list__grid {
|
||||
@@ -40,6 +39,10 @@ defineProps<{
|
||||
}
|
||||
|
||||
.c-participants-list__item {
|
||||
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
@apply overflow-hidden p-4;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--foreground);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,18 +21,20 @@ defineProps<{
|
||||
url: string;
|
||||
unread_count: number;
|
||||
context_activities: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
user: { name: string };
|
||||
id: number;
|
||||
content: string;
|
||||
user: { name: string } | null;
|
||||
subject: { name: string; url: string } | null;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}>;
|
||||
new_activities: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
user: { name: string };
|
||||
id: number;
|
||||
content: string;
|
||||
user: { name: string } | null;
|
||||
subject: { name: string; url: string } | null;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}>;
|
||||
}>;
|
||||
}>();
|
||||
@@ -62,7 +64,7 @@ function formatTime(isoString: string): string {
|
||||
'c-dashboard__badge-type',
|
||||
entity.type === 'Dynamic'
|
||||
? 'c-dashboard__badge-type--dynamic'
|
||||
: 'c-dashboard__badge-type--ledger'
|
||||
: 'c-dashboard__badge-type--ledger',
|
||||
]"
|
||||
>
|
||||
{{ entity.type }}
|
||||
@@ -71,7 +73,10 @@ function formatTime(isoString: string): string {
|
||||
{{ entity.unread_count }} New
|
||||
</span>
|
||||
</div>
|
||||
<Link :href="entity.url" class="c-dashboard__entity-link">
|
||||
<Link
|
||||
:href="entity.url"
|
||||
class="c-dashboard__entity-link"
|
||||
>
|
||||
<h3 class="c-dashboard__entity-title">
|
||||
{{ entity.name }}
|
||||
</h3>
|
||||
@@ -85,17 +90,19 @@ function formatTime(isoString: string): string {
|
||||
:key="activity.id"
|
||||
class="c-dashboard__activity-item c-dashboard__activity-item--read"
|
||||
>
|
||||
<Link :href="activity.url" class="block">
|
||||
<div class="c-dashboard__activity-meta">
|
||||
<span class="c-dashboard__activity-user">
|
||||
{{ activity.user.name }}
|
||||
{{ activity.user?.name || 'System' }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.description }}
|
||||
{{ activity.content }}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<!-- Unread Separator Line -->
|
||||
@@ -103,7 +110,9 @@ function formatTime(isoString: string): string {
|
||||
v-if="entity.context_activities.length > 0"
|
||||
class="c-dashboard__divider"
|
||||
>
|
||||
<span class="c-dashboard__divider-text">New Activity Below</span>
|
||||
<span class="c-dashboard__divider-text"
|
||||
>New Activity Below</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- New / Unread Activities -->
|
||||
@@ -112,9 +121,10 @@ function formatTime(isoString: string): string {
|
||||
: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 }}
|
||||
{{ activity.user?.name || 'System' }}
|
||||
</span>
|
||||
<span class="c-dashboard__activity-time">
|
||||
{{ formatTime(activity.created_at) }}
|
||||
@@ -122,8 +132,9 @@ function formatTime(isoString: string): string {
|
||||
<span class="c-dashboard__new-badge">NEW</span>
|
||||
</div>
|
||||
<p class="c-dashboard__activity-desc">
|
||||
{{ activity.description }}
|
||||
{{ activity.content }}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,9 +142,7 @@ function formatTime(isoString: string): string {
|
||||
|
||||
<!-- Empty Caught-Up State -->
|
||||
<div v-else class="c-dashboard__empty-state">
|
||||
<div class="c-dashboard__empty-icon">
|
||||
🔒
|
||||
</div>
|
||||
<div class="c-dashboard__empty-icon">🔒</div>
|
||||
<p class="c-dashboard__empty-text">
|
||||
All chambers are currently quiet.
|
||||
</p>
|
||||
@@ -149,7 +158,7 @@ function formatTime(isoString: string): string {
|
||||
@reference "../../css/app.css";
|
||||
|
||||
.c-dashboard {
|
||||
@apply py-8 px-4 sm:px-6 lg:px-8;
|
||||
@apply px-4 py-8 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.c-dashboard__container {
|
||||
@@ -157,7 +166,7 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__title {
|
||||
@apply text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 mb-6;
|
||||
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
|
||||
}
|
||||
|
||||
.c-dashboard__grid {
|
||||
@@ -169,15 +178,15 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__card-header {
|
||||
@apply p-6 border-b border-neutral-100 dark:border-neutral-800/30;
|
||||
@apply border-b border-neutral-100 p-6 dark:border-neutral-800/30;
|
||||
}
|
||||
|
||||
.c-dashboard__entity-meta {
|
||||
@apply flex items-center gap-2 mb-2;
|
||||
@apply mb-2 flex items-center gap-2;
|
||||
}
|
||||
|
||||
.c-dashboard__badge-type {
|
||||
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded;
|
||||
@apply rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase;
|
||||
}
|
||||
|
||||
.c-dashboard__badge-type--dynamic {
|
||||
@@ -189,7 +198,7 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__unread-count {
|
||||
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-red-100 text-red-800 dark:bg-red-950/20 dark:text-red-400;
|
||||
@apply rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-bold tracking-wider text-red-800 uppercase dark:bg-red-950/20 dark:text-red-400;
|
||||
}
|
||||
|
||||
.c-dashboard__entity-link {
|
||||
@@ -201,23 +210,23 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__activity-list {
|
||||
@apply flex-1 p-6 space-y-4;
|
||||
@apply flex-1 space-y-4 p-6;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-item {
|
||||
@apply p-4 rounded-lg border;
|
||||
@apply rounded-lg border p-4;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-item--read {
|
||||
@apply bg-neutral-50/50 border-neutral-200 opacity-60 dark:bg-neutral-950/20 dark:border-neutral-800/50;
|
||||
@apply border-neutral-200 bg-neutral-50/50 opacity-60 dark:border-neutral-800/50 dark:bg-neutral-950/20;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-item--unread {
|
||||
@apply bg-red-50/10 border-red-200/50 dark:bg-red-950/5 dark:border-red-900/30;
|
||||
@apply border-red-200/50 bg-red-50/10 dark:border-red-900/30 dark:bg-red-950/5;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-meta {
|
||||
@apply flex items-center gap-2 mb-1.5 text-xs;
|
||||
@apply mb-1.5 flex items-center gap-2 text-xs;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-user {
|
||||
@@ -229,7 +238,7 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__new-badge {
|
||||
@apply ml-auto text-[9px] font-bold text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-950/30 px-1 py-0.5 rounded;
|
||||
@apply ml-auto rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-600 dark:bg-red-950/30 dark:text-red-400;
|
||||
}
|
||||
|
||||
.c-dashboard__activity-desc {
|
||||
@@ -237,11 +246,11 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__divider {
|
||||
@apply relative flex items-center justify-center my-4;
|
||||
@apply relative my-4 flex items-center justify-center;
|
||||
}
|
||||
|
||||
.c-dashboard__divider-text {
|
||||
@apply bg-white dark:bg-neutral-900 px-3 text-[10px] font-bold uppercase tracking-widest text-neutral-400 dark:text-neutral-500 z-10;
|
||||
@apply z-10 bg-white px-3 text-[10px] font-bold tracking-widest text-neutral-400 uppercase dark:bg-neutral-900 dark:text-neutral-500;
|
||||
}
|
||||
|
||||
.c-dashboard__divider::before {
|
||||
@@ -250,11 +259,11 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__empty-state {
|
||||
@apply flex flex-col items-center justify-center p-12 text-center rounded-2xl border border-dashed border-neutral-200 dark:border-neutral-800 min-h-[300px];
|
||||
@apply flex min-h-[300px] flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-200 p-12 text-center dark:border-neutral-800;
|
||||
}
|
||||
|
||||
.c-dashboard__empty-icon {
|
||||
@apply text-4xl mb-4;
|
||||
@apply mb-4 text-4xl;
|
||||
}
|
||||
|
||||
.c-dashboard__empty-text {
|
||||
@@ -262,6 +271,6 @@ function formatTime(isoString: string): string {
|
||||
}
|
||||
|
||||
.c-dashboard__empty-subtext {
|
||||
@apply mt-1 text-sm text-neutral-500 dark:text-neutral-500 max-w-md;
|
||||
@apply mt-1 max-w-md text-sm text-neutral-500 dark:text-neutral-500;
|
||||
}
|
||||
</style>
|
||||
@@ -30,13 +30,16 @@ function submit() {
|
||||
<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>
|
||||
<h3 class="c-dynamics-create__title">
|
||||
Create a New Dynamic
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="submit" class="c-dynamics-create__form">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="c-dynamics-create__form"
|
||||
>
|
||||
<div class="c-dynamics-create__field">
|
||||
<label
|
||||
for="name"
|
||||
class="c-dynamics-create__label"
|
||||
<label for="name" class="c-dynamics-create__label"
|
||||
>Name</label
|
||||
>
|
||||
<input
|
||||
@@ -54,9 +57,7 @@ function submit() {
|
||||
</div>
|
||||
|
||||
<div class="c-dynamics-create__field">
|
||||
<label
|
||||
for="rules"
|
||||
class="c-dynamics-create__label"
|
||||
<label for="rules" class="c-dynamics-create__label"
|
||||
>Rules</label
|
||||
>
|
||||
<textarea
|
||||
|
||||
@@ -51,7 +51,9 @@ const breadcrumbs = [
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="c-dynamics-index__empty">You don't have any dynamics yet.</p>
|
||||
<p class="c-dynamics-index__empty">
|
||||
You don't have any dynamics yet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 = [
|
||||
@@ -41,11 +42,18 @@ const breadcrumbs = [
|
||||
<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 -->
|
||||
@@ -57,8 +65,14 @@ 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>
|
||||
@@ -75,11 +89,15 @@ const breadcrumbs = [
|
||||
}
|
||||
|
||||
.c-dynamic-show__card {
|
||||
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||
@apply overflow-hidden;
|
||||
background-color: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.c-dynamic-show__body {
|
||||
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||
@apply p-6;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.c-dynamic-show__title {
|
||||
@@ -87,6 +105,15 @@ const breadcrumbs = [
|
||||
}
|
||||
|
||||
.c-dynamic-show__rules {
|
||||
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||
@apply mt-2 text-sm;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.c-dynamic-show__settings-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
|
||||
.c-dynamic-show__action-btn {
|
||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -188,7 +188,8 @@ function isOwnerUser(userId: number): boolean {
|
||||
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).
|
||||
▼ Negative Alignment — A lower score is better
|
||||
(demerits / infractions).
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
@@ -249,29 +250,20 @@ function isOwnerUser(userId: number): boolean {
|
||||
</div>
|
||||
|
||||
<!-- Lightbox Modal -->
|
||||
<div
|
||||
v-if="activeLightboxUrl"
|
||||
class="c-ledger-show__lightbox"
|
||||
@click="closeLightbox"
|
||||
>
|
||||
<button
|
||||
@click="closeLightbox"
|
||||
class="c-ledger-show__lightbox-close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div class="c-ledger-show__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-ledger-show__lightbox-img"
|
||||
class="c-lightbox__image"
|
||||
/>
|
||||
<video
|
||||
v-else-if="activeLightboxType === 'video'"
|
||||
:src="activeLightboxUrl"
|
||||
controls
|
||||
autoplay
|
||||
class="c-ledger-show__lightbox-video"
|
||||
class="c-lightbox__video"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
@@ -325,19 +317,19 @@ function isOwnerUser(userId: number): boolean {
|
||||
}
|
||||
|
||||
.c-ledger-show__alignment-badge {
|
||||
@apply text-xs font-semibold uppercase tracking-wide px-2.5 py-1 rounded;
|
||||
@apply rounded px-2.5 py-1 text-xs font-semibold tracking-wide uppercase;
|
||||
}
|
||||
|
||||
.c-ledger-show__alignment-badge--positive {
|
||||
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20;
|
||||
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
|
||||
}
|
||||
|
||||
.c-ledger-show__alignment-badge--negative {
|
||||
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20;
|
||||
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
|
||||
}
|
||||
|
||||
.c-ledger-show__alignment-badge--neutral {
|
||||
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20;
|
||||
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.c-ledger-show__media-list {
|
||||
@@ -363,24 +355,4 @@ function isOwnerUser(userId: number): boolean {
|
||||
.c-ledger-show__media-video-overlay {
|
||||
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
|
||||
}
|
||||
|
||||
.c-ledger-show__lightbox {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
|
||||
}
|
||||
|
||||
.c-ledger-show__lightbox-close {
|
||||
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
|
||||
}
|
||||
|
||||
.c-ledger-show__lightbox-content {
|
||||
@apply max-h-full max-w-full;
|
||||
}
|
||||
|
||||
.c-ledger-show__lightbox-img {
|
||||
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
|
||||
}
|
||||
|
||||
.c-ledger-show__lightbox-video {
|
||||
@apply max-h-[90vh] max-w-full rounded shadow-lg;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<x-mail::message>
|
||||
# You have been invited!
|
||||
|
||||
Hello,
|
||||
|
||||
**{{ $inviterName }}** has invited you to join their Dynamic: **{{ $dynamicName }}** as a **{{ strtoupper($role) }}**.
|
||||
|
||||
Only the user registered with this email address can accept this invitation. The invitation link will be valid for 7 days.
|
||||
|
||||
<x-mail::button :url="$acceptUrl">
|
||||
Accept Invitation
|
||||
</x-mail::button>
|
||||
|
||||
If you do not have an account yet, please register using this email address first, then click the button above.
|
||||
|
||||
Thanks,<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
+16
-2
@@ -12,12 +12,26 @@ Route::inertia('/', 'Welcome')->name('home');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||
|
||||
Route::resource('dynamics', DynamicController::class);
|
||||
Route::resource('dynamics.ledgers', LedgerController::class)->scoped();
|
||||
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.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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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!
|
||||
});
|
||||
});
|
||||
@@ -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!');
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,9 @@ test('dashboard groups and filters unread entities correctly based on cursor', f
|
||||
->where('unreadEntities.0.name', 'Testing Dynamic')
|
||||
->where('unreadEntities.0.unread_count', 1)
|
||||
->has('unreadEntities.0.context_activities', 1) // Should have old message as context
|
||||
->where('unreadEntities.0.context_activities.0.description', 'Old message context')
|
||||
->where('unreadEntities.0.context_activities.0.content', 'Old message context')
|
||||
->has('unreadEntities.0.new_activities', 1) // Should have unread message
|
||||
->where('unreadEntities.0.new_activities.0.description', 'New unread message alert')
|
||||
->where('unreadEntities.0.new_activities.0.content', 'New unread message alert')
|
||||
);
|
||||
|
||||
// Now visit the Dynamic, which clears the unread count
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user