added media, mutation events, agent instructions
Some checks failed
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m4s

This commit is contained in:
Daan Meijer 2026-06-15 22:30:17 +02:00
parent 1d1ca88aea
commit a1adf1da1c
44 changed files with 2083 additions and 287 deletions

View File

@ -5,6 +5,11 @@
The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Persistent Project Context (IMPORTANT)
- You MUST read, understand, and strictly follow the underlying business logic and feature requests documented in `IDEA.md` in every session.
- You MUST adhere to all style architecture, backend transaction, and integration decisions recorded in `DECISIONS.md`. Update `DECISIONS.md` when any major design decisions are made during a session.
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.

View File

@ -13,9 +13,23 @@ This document outlines the decisions made during the development of the Ledgerrz
* **Backend:** Laravel * **Backend:** Laravel
* **Frontend:** Vue.js with Inertia.js * **Frontend:** Vue.js with Inertia.js
* **Real-time:** Laravel Reverb for notifications and real-time updates. * **Real-time:** Laravel Echo via `@laravel/echo-vue` for notifications and real-time updates.
* **Testing:** Pest for PHP tests. * **Testing:** Pest for PHP tests.
* **Styling:** Tailwind CSS (based on the presence of `tailwindcss-development` skill). * **Styling:** **BEM (Block, Element, Modifier)** methodology. Replaced verbose inline Tailwind classes in custom HTML templates with semantic BEM classes (e.g., `.c-chat`, `.user-info__avatar`), and mapped them to CSS rules using Tailwind v4's `@apply` directive inside scoped `<style>` blocks. This preserves the clean, dark, BDSM-themed aesthetic while significantly improving markup readability.
## 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.
### 2. 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
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`.
## Initial Database Schema ## Initial Database Schema

17
GEMINI.md Normal file
View File

@ -0,0 +1,17 @@
# Ledgerrz Project Instructions & Conventions
Welcome to the Ledgerrz codebase! This file defines the persistent guidelines, architectural rules, and context directories loaded in every Gemini session.
---
## 1. Context Persistence Mandate
* **Business Logic & Goals (`IDEA.md`):** You MUST read and follow the application concept, features, and user workflow goals outlined in `IDEA.md` at the start of any new session.
* **Design & Architecture Decisions (`DECISIONS.md`):** You MUST strictly adhere to the established style architecture (BEM methodology with scoped styles and `@apply`), package deduplication rules, and controller structures documented in `DECISIONS.md`. Update this file with any new major design decisions made during your session.
---
## 2. Key Technology Stack & Conventions
* **PHP/Laravel:** PHP 8.4 & Laravel 13. Adhere to typed parameters and return values. Ensure controllers extend properly and use required authorization traits (e.g., `AuthorizesRequests`).
* **Frontend Styling (BEM):** Replaced direct Tailwind inline utility-class markup with **BEM (Block, Element, Modifier)**. All custom component styles must live inside `<style scoped>` blocks with a relative `@reference "../../css/app.css"` directive to pull variables without duplications.
* **Real-time Broadcasting:** Powered by `@laravel/echo-vue` with fallback configurations and Vite deduplication rules configured in `vite.config.ts`.
* **Testing:** Powered by Pest PHP (v4). Every backend controller, event, or model change must be validated by running `vendor/bin/pest`.

View File

@ -37,7 +37,7 @@ class MessageSent implements ShouldBroadcast
public function broadcastWith(): array public function broadcastWith(): array
{ {
return [ return [
'message' => $this->message->load('user'), 'message' => $this->message->load('user', 'media'),
]; ];
} }
} }

View File

@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Mutation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MutationCreated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Mutation $mutation)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$chatId = $this->mutation->ledger->dynamic->chat->id;
return [
new PrivateChannel('chats.'.$chatId),
];
}
public function broadcastWith(): array
{
return [
'mutation' => $this->mutation->load('user', 'ledger', 'media'),
'type' => 'created',
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Events;
use App\Models\Mutation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class MutationUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Mutation $mutation)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
$chatId = $this->mutation->ledger->dynamic->chat->id;
return [
new PrivateChannel('chats.'.$chatId),
];
}
public function broadcastWith(): array
{
return [
'mutation' => $this->mutation->load('user', 'ledger', 'media'),
'type' => 'updated',
];
}
}

View File

@ -48,7 +48,12 @@ class DynamicController extends Controller
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
$dynamic->load('ledgers', 'participants', 'chat.messages.user'); $dynamic->load([
'ledgers.media',
'participants',
'chat.messages.user',
'chat.messages.media'
]);
return Inertia::render('Dynamics/Show', [ return Inertia::render('Dynamics/Show', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,

View File

@ -6,10 +6,13 @@ use App\Http\Requests\StoreLedgerRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia; use Inertia\Inertia;
class LedgerController extends Controller class LedgerController extends Controller
{ {
use AuthorizesRequests;
/** /**
* Display a listing of the resource. * Display a listing of the resource.
*/ */
@ -31,7 +34,18 @@ class LedgerController extends Controller
*/ */
public function store(StoreLedgerRequest $request, Dynamic $dynamic) public function store(StoreLedgerRequest $request, Dynamic $dynamic)
{ {
$dynamic->ledgers()->create($request->validated()); $ledger = $dynamic->ledgers()->create($request->except('media'));
if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$ledger->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
return redirect()->route('dynamics.show', $dynamic); return redirect()->route('dynamics.show', $dynamic);
} }
@ -39,15 +53,32 @@ class LedgerController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Dynamic $dynamic, Ledger $ledger) public function show(Request $request, Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('view', $ledger); $this->authorize('view', $ledger);
$ledger->load('mutations.user', 'mutations.chat.messages.user'); $dynamic->load('chat', 'participants');
$ledger->load([
'media',
'mutations' => function ($query) {
$query->latest();
},
'mutations.user',
'mutations.media',
'mutations.chat.messages.user',
'mutations.chat.messages.media'
]);
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
return Inertia::render('Ledgers/Show', [ return Inertia::render('Ledgers/Show', [
'dynamic' => $dynamic, 'dynamic' => $dynamic,
'ledger' => $ledger, 'ledger' => $ledger,
'isOwner' => $isOwner,
]); ]);
} }

View File

@ -32,10 +32,24 @@ class MessageController extends Controller
public function store(StoreMessageRequest $request, Chat $chat) public function store(StoreMessageRequest $request, Chat $chat)
{ {
$message = $chat->messages()->create([ $message = $chat->messages()->create([
...$request->validated(), ...$request->except('media'),
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
]); ]);
if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$message->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
// Eager-load relations before broadcasting
$message->load('media', 'user');
broadcast(new MessageSent($message)); broadcast(new MessageSent($message));
return redirect()->back(); return redirect()->back();

View File

@ -32,15 +32,44 @@ class MutationController extends Controller
*/ */
public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger) public function store(StoreMutationRequest $request, Dynamic $dynamic, Ledger $ledger)
{ {
DB::transaction(function () use ($request, $ledger) { $isOwner = $dynamic->participants()
$ledger->mutations()->create([ ->where('user_id', $request->user()->id)
...$request->validated(), ->where('role', 'owner')
->exists();
// If the user is an owner, default status to 'approved'. Otherwise default to 'pending'.
$status = $isOwner ? 'approved' : 'pending';
$mutation = DB::transaction(function () use ($request, $ledger, $status) {
$mutation = $ledger->mutations()->create([
...$request->except(['media', 'type', 'status']),
'user_id' => $request->user()->id, 'user_id' => $request->user()->id,
'type' => $request->input('type', $request->input('amount') >= 0 ? 'addition' : 'subtraction'),
'status' => $status,
]); ]);
$ledger->increment('score', $request->validated('amount')); if ($request->hasFile('media')) {
foreach ($request->file('media') as $file) {
$path = $file->store('media', 'public');
$mutation->media()->create([
'file_path' => $path,
'file_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
]);
}
}
// Only increment score if the status is approved!
if ($status === 'approved') {
$ledger->increment('score', $request->validated('amount'));
}
return $mutation;
}); });
// Broadcast the real-time creation event!
broadcast(new \App\Events\MutationCreated($mutation));
return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]); return redirect()->route('dynamics.ledgers.show', [$dynamic, $ledger]);
} }
@ -65,7 +94,61 @@ class MutationController extends Controller
*/ */
public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation) public function update(Request $request, Dynamic $dynamic, Ledger $ledger, Mutation $mutation)
{ {
// // 1. Authorize - only owners can update mutation status!
$isOwner = $dynamic->participants()
->where('user_id', $request->user()->id)
->where('role', 'owner')
->exists();
if (!$isOwner) {
abort(403, 'Only dynamic owners can approve or reject mutations.');
}
$request->validate([
'status' => ['required', 'string', 'in:approved,rejected'],
]);
$oldStatus = $mutation->status;
$newStatus = $request->input('status');
DB::transaction(function () use ($mutation, $ledger, $oldStatus, $newStatus) {
$mutation->update(['status' => $newStatus]);
// Adjust the ledger score if status transitions to approved or from approved!
if ($oldStatus !== 'approved' && $newStatus === 'approved') {
$ledger->increment('score', $mutation->amount);
} elseif ($oldStatus === 'approved' && $newStatus !== 'approved') {
$ledger->decrement('score', $mutation->amount);
}
});
// Log to Mutation and Dynamic chats
$user = $request->user();
$statusText = strtoupper($newStatus);
$mutationMsg = $mutation->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: Suggestion was {$statusText} by {$user->name}.",
]);
broadcast(new \App\Events\MessageSent($mutationMsg));
if ($newStatus === 'approved') {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} APPROVED the suggestion \"{$mutation->description}\" for " . ($mutation->amount >= 0 ? '+' : '') . "{$mutation->amount} points on \"{$ledger->name}\" ledger.",
]);
} else {
$dynamicMsg = $dynamic->chat->messages()->create([
'user_id' => $user->id,
'content' => "System: {$user->name} REJECTED the suggestion \"{$mutation->description}\" on \"{$ledger->name}\" ledger.",
]);
}
broadcast(new \App\Events\MessageSent($dynamicMsg));
// Broadcast the real-time update event!
broadcast(new \App\Events\MutationUpdated($mutation));
return redirect()->back();
} }
/** /**

View File

@ -27,6 +27,8 @@ class StoreLedgerRequest extends FormRequest
return [ return [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'rules' => ['nullable', 'string'], 'rules' => ['nullable', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
]; ];
} }
} }

View File

@ -26,6 +26,8 @@ class StoreMessageRequest extends FormRequest
{ {
return [ return [
'content' => ['required', 'string'], 'content' => ['required', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
]; ];
} }
} }

View File

@ -29,6 +29,8 @@ class StoreMutationRequest extends FormRequest
'description' => ['required', 'string'], 'description' => ['required', 'string'],
'type' => ['nullable', 'string'], 'type' => ['nullable', 'string'],
'status' => ['nullable', 'string'], 'status' => ['nullable', 'string'],
'media' => ['nullable', 'array'],
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
]; ];
} }
} }

View File

@ -21,7 +21,7 @@ class Dynamic extends Model
public function participants(): BelongsToMany public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'participants'); return $this->belongsToMany(User::class, 'participants')->withPivot('role');
} }
public function ledgers(): HasMany public function ledgers(): HasMany

View File

@ -29,4 +29,9 @@ class Ledger extends Model
{ {
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
} }

27
app/Models/Media.php Normal file
View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Media extends Model {
use HasFactory;
protected $fillable = [
'file_path',
'file_name',
'mime_type',
];
protected $appends = ['url'];
public function mediable(): MorphTo {
return $this->morphTo();
}
public function getUrlAttribute(): string {
return asset('storage/' . $this->file_path);
}
}

View File

@ -27,4 +27,9 @@ class Message extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
} }

View File

@ -37,6 +37,11 @@ class Mutation extends Model
return $this->morphOne(Chat::class, 'chatable'); return $this->morphOne(Chat::class, 'chatable');
} }
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{
return $this->morphMany(Media::class, 'mediable');
}
protected static function booted(): void protected static function booted(): void
{ {
static::created(function (Mutation $mutation) { static::created(function (Mutation $mutation) {

View File

@ -0,0 +1,19 @@
<?php
namespace App\Policies;
use App\Models\Mutation;
use App\Models\User;
class MutationPolicy
{
/**
* Determine whether the user can view the mutation.
*/
public function view(User $user, Mutation $mutation): bool
{
$dynamic = $mutation->ledger->dynamic;
return $dynamic->participants()->where('user_id', $user->id)->exists();
}
}

View File

@ -4,8 +4,10 @@ namespace App\Providers;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Mutation;
use App\Policies\DynamicPolicy; use App\Policies\DynamicPolicy;
use App\Policies\LedgerPolicy; use App\Policies\LedgerPolicy;
use App\Policies\MutationPolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider class AuthServiceProvider extends ServiceProvider
@ -18,6 +20,7 @@ class AuthServiceProvider extends ServiceProvider
protected $policies = [ protected $policies = [
Dynamic::class => DynamicPolicy::class, Dynamic::class => DynamicPolicy::class,
Ledger::class => LedgerPolicy::class, Ledger::class => LedgerPolicy::class,
Mutation::class => MutationPolicy::class,
]; ];
/** /**

View File

@ -18,7 +18,8 @@ class ChatFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
// 'chatable_type' => 'App\\Models\\Dynamic',
'chatable_id' => 1,
]; ];
} }
} }

View File

@ -18,7 +18,14 @@ class DynamicFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
// 'name' => $this->faker->randomElement([
'The Velvet Sanctuary',
'Obsidian Household Agreement',
'Crimson Castle Protocol',
'Dungeon Master-Sub Board',
'Coffee Club Ledger'
]),
'rules' => "1. All rules must be strictly adhered to.\n2. Scores must be updated after every task.\n3. Disputed scores can be discussed in the dedicated chat.",
]; ];
} }
} }

View File

@ -3,6 +3,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Ledger; use App\Models\Ledger;
use App\Models\Dynamic;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -18,7 +19,17 @@ class LedgerFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
// 'dynamic_id' => Dynamic::factory(),
'name' => $this->faker->randomElement([
'Curfew Compliance',
'Worship & Praise',
'Dungeon Cleaning',
'Silence Protocol',
'Task Completion',
'Tribute Points'
]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'score' => 0,
]; ];
} }
} }

View File

@ -3,6 +3,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Message; use App\Models\Message;
use App\Models\Chat;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -18,7 +20,9 @@ class MessageFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
// 'chat_id' => Chat::factory(),
'user_id' => User::factory(),
'content' => $this->faker->sentence(),
]; ];
} }
} }

View File

@ -3,6 +3,8 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Mutation; use App\Models\Mutation;
use App\Models\Ledger;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
/** /**
@ -18,7 +20,12 @@ class MutationFactory extends Factory
public function definition(): array public function definition(): array
{ {
return [ return [
// 'ledger_id' => Ledger::factory(),
'user_id' => User::factory(),
'type' => $this->faker->randomElement(['addition', 'subtraction', 'reward', 'penalty']),
'amount' => $this->faker->randomElement([5, 10, 15, -5, -10, -25]),
'description' => $this->faker->sentence(),
'status' => $this->faker->randomElement(['approved', 'pending', 'rejected']),
]; ];
} }
} }

View File

@ -0,0 +1,22 @@
<?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('media', function (Blueprint $table) {
$table->id();
$table->morphs('mediable'); // mediable_type, mediable_id
$table->string('file_path');
$table->string('file_name');
$table->string('mime_type');
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('media');
}
};

View File

@ -3,23 +3,328 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents;
/** /**
* Seed the application's database. * Seed the application's database.
*/ */
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); DB::transaction(function () {
// 1. Create Core Users
$testUser = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
'password' => Hash::make('password'),
]);
User::factory()->create([ $alice = User::factory()->create([
'name' => 'Test User', 'name' => 'Domina Alice',
'email' => 'test@example.com', 'email' => 'alice@example.com',
]); 'password' => Hash::make('password'),
]);
$bob = User::factory()->create([
'name' => 'Submissive Bob',
'email' => 'bob@example.com',
'password' => Hash::make('password'),
]);
$charles = User::factory()->create([
'name' => 'Master Charles',
'email' => 'charles@example.com',
'password' => Hash::make('password'),
]);
// Create some random general users
User::factory(5)->create();
// ----------------------------------------------------
// 2. Seed Dynamic 1: The Velvet Sanctuary
// ----------------------------------------------------
$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.",
]);
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
$velvetSanctuary->participants()->attach($testUser->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($alice->id, ['role' => 'owner']);
$velvetSanctuary->participants()->attach($bob->id, ['role' => 'participant']);
// Chat has been auto-created by the booted hook on Dynamic
$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.',
]);
// Add Ledgers
$curfewLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id,
'name' => 'Curfew Compliance',
'rules' => 'Must be in bed and checked in by 11:00 PM.',
'score' => 35,
]);
$cleaningLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id,
'name' => 'Dungeon Cleaning',
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
'score' => 45,
]);
$etiquetteLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id,
'name' => 'Protocol & Etiquette',
'rules' => 'Address others by correct titles. -5 per infraction.',
'score' => -15,
]);
// 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',
]);
// Seed Cleaning Mutations
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 15,
'description' => 'Deep cleaning of the main chamber',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 20,
'description' => 'Arranged gear rack and polished leather accessories',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 10,
'description' => 'Mopped obsidian floors',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $cleaningLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Left keys in the locks unmonitored',
'status' => 'approved',
]);
// 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)
$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!',
]);
// Seed Etiquette Mutations
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -5,
'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -10,
'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'penalty',
'amount' => -5,
'description' => 'Spoke out of turn in general chat',
'status' => 'approved',
]);
Mutation::create([
'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id,
'type' => 'reward',
'amount' => 5,
'description' => 'Excellent reciting of the house codes',
'status' => 'approved',
]);
// ----------------------------------------------------
// 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.",
]);
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
$obsidianHousehold->participants()->attach($testUser->id, ['role' => 'participant']);
$obsidianHousehold->participants()->attach($charles->id, ['role' => 'participant']);
$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.',
]);
// Add Ledgers
$kitchenLedger = Ledger::create([
'dynamic_id' => $obsidianHousehold->id,
'name' => 'Kitchen Chores',
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
'score' => 40,
]);
$coffeeLedger = Ledger::create([
'dynamic_id' => $obsidianHousehold->id,
'name' => 'Coffee Machine Maintenance',
'rules' => ' refill beans and descale monthly.',
'score' => 10,
]);
// 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',
]);
// 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',
]);
});
} }
} }

View File

@ -8,8 +8,18 @@ import { configureEcho } from '@laravel/echo-vue';
configureEcho({ configureEcho({
broadcaster: 'reverb', broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY || 'mock-key',
wsHost: import.meta.env.VITE_REVERB_HOST || 'localhost',
wsPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
wssPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
forceTLS: false,
enabledTransports: ['ws', 'wss'],
}); });
(window as any).echoConfigured = true;
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

View File

@ -18,11 +18,15 @@ const className = computed(() => props.class);
<SidebarInset v-if="props.variant === 'sidebar'" :class="className"> <SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot /> <slot />
</SidebarInset> </SidebarInset>
<main <main v-else class="app-content" :class="className">
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
:class="className"
>
<slot /> <slot />
</main> </main>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-content {
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
}
</style>

View File

@ -3,14 +3,30 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script> </script>
<template> <template>
<div <div class="app-logo__icon-container">
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground" <AppLogoIcon class="app-logo__icon" />
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div> </div>
<div class="ml-1 grid flex-1 text-left text-sm"> <div class="app-logo__text-container">
<span class="mb-0.5 truncate leading-tight font-semibold" <span class="app-logo__text">Laravel Starter Kit</span>
>Laravel Starter Kit</span
>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.app-logo__icon-container {
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
}
.app-logo__icon {
@apply size-5 fill-current text-white dark:text-black;
}
.app-logo__text-container {
@apply ml-1 grid flex-1 text-left text-sm;
}
.app-logo__text {
@apply mb-0.5 truncate leading-tight font-semibold;
}
</style>

View File

@ -14,10 +14,8 @@ withDefaults(
</script> </script>
<template> <template>
<header <header class="sidebar-header">
class="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" <div class="sidebar-header__inner">
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" /> <SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0"> <template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" /> <Breadcrumbs :breadcrumbs="breadcrumbs" />
@ -25,3 +23,15 @@ withDefaults(
</div> </div>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.sidebar-header {
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
}
.sidebar-header__inner {
@apply flex items-center gap-2;
}
</style>

View File

@ -12,22 +12,42 @@ const tabs = [
</script> </script>
<template> <template>
<div <div class="appearance-tabs">
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
>
<button <button
v-for="{ value, Icon, label } in tabs" v-for="{ value, Icon, label } in tabs"
:key="value" :key="value"
@click="updateAppearance(value)" @click="updateAppearance(value)"
:class="[ :class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors', 'appearance-tabs__tab',
appearance === value { 'appearance-tabs__tab--active': appearance === value },
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]" ]"
> >
<component :is="Icon" class="-ml-1 h-4 w-4" /> <component :is="Icon" class="appearance-tabs__icon" />
<span class="ml-1.5 text-sm">{{ label }}</span> <span class="appearance-tabs__label">{{ label }}</span>
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.appearance-tabs {
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
}
.appearance-tabs__tab {
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
}
.appearance-tabs__tab--active {
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
}
.appearance-tabs__icon {
@apply -ml-1 h-4 w-4;
}
.appearance-tabs__label {
@apply ml-1.5 text-sm;
}
</style>

View File

@ -1,59 +1,377 @@
<script setup> <script setup lang="ts">
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import { onMounted } from 'vue'; import { Paperclip } from '@lucide/vue';
import { useEcho } from '@laravel/echo-vue';
const props = defineProps({ const props = defineProps<{
chat: Object, chat: {
}); id: number;
messages: Array<{
id: number;
user: { name: string };
content: string;
created_at: string;
media?: Array<{
id: number;
url: string;
file_name: string;
mime_type: string;
}>;
}>;
};
}>();
if (!echoIsConfigured()) {
configureEcho({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY || 'mock-key',
wsHost: import.meta.env.VITE_REVERB_HOST || 'localhost',
wsPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
wssPort: import.meta.env.VITE_REVERB_PORT
? Number(import.meta.env.VITE_REVERB_PORT)
: 8080,
forceTLS: false,
enabledTransports: ['ws', 'wss'],
});
}
const fileInput = ref<HTMLInputElement | null>(null);
const form = useForm({ const form = useForm({
content: '', content: '',
media: [] as File[],
}); });
const echo = useEcho(); useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
props.chat.messages.push(e.message);
onMounted(() => {
echo.private(`chats.${props.chat.id}`)
.listen('MessageSent', (e) => {
props.chat.messages.push(e.message);
});
}); });
function handleFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeFile(index: number) {
form.media.splice(index, 1);
}
function submit() { function submit() {
form.post(route('chats.messages.store', props.chat.id), { form.post(route('chats.messages.store', props.chat.id), {
onSuccess: () => form.reset(), onSuccess: () => {
form.reset();
if (fileInput.value) {
fileInput.value.value = '';
}
},
}); });
} }
// Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null);
const activeLightboxType = ref<'image' | 'video' | null>(null);
function openLightbox(url: string, mimeType: string) {
activeLightboxUrl.value = url;
activeLightboxType.value = mimeType.startsWith('image/')
? 'image'
: 'video';
}
function closeLightbox() {
activeLightboxUrl.value = null;
activeLightboxType.value = null;
}
</script> </script>
<template> <template>
<div class="mt-8"> <div class="c-chat">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Chat</h4> <h4 class="c-chat__title">Chat</h4>
<div class="mt-4 space-y-4"> <div class="c-chat__list">
<div v-for="message in chat.messages" :key="message.id" class="p-4 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div
<div class="flex justify-between"> v-for="message in chat.messages"
<span class="font-semibold">{{ message.user.name }}</span> :key="message.id"
<span class="text-xs text-gray-500">{{ new Date(message.created_at).toLocaleString() }}</span> class="c-chat__message"
>
<div class="c-chat__message-header">
<span class="c-chat__message-author">{{
message.user.name
}}</span>
<span class="c-chat__message-time">{{
new Date(message.created_at).toLocaleString()
}}</span>
</div>
<p class="c-chat__message-text">
{{ message.content }}
</p>
<!-- Attached Media Display -->
<div
v-if="message.media && message.media.length > 0"
class="c-chat__message-media"
>
<div
v-for="item in message.media"
:key="item.id"
class="c-chat__media-item"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
:alt="item.file_name"
class="c-chat__image cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
/>
<div
v-else-if="item.mime_type.startsWith('video/')"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)"
>
<video
:src="item.url"
class="c-chat__video"
></video>
<div class="c-chat__play-overlay"></div>
</div>
</div>
</div> </div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ message.content }}</p>
</div> </div>
<div v-if="chat.messages.length === 0" class="text-gray-500"> <div v-if="chat.messages.length === 0" class="c-chat__empty">
No messages yet. No messages yet.
</div> </div>
</div> </div>
<form @submit.prevent="submit" class="mt-6 space-y-6"> <form @submit.prevent="submit" class="c-chat__form">
<div> <div class="c-chat__form-group">
<label for="content" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Message</label> <label for="content" class="c-chat__label">Message</label>
<textarea v-model="form.content" id="content" rows="3" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea> <textarea
<div v-if="form.errors.content" class="text-sm text-red-600">{{ form.errors.content }}</div> v-model="form.content"
id="content"
rows="3"
class="c-chat__textarea"
placeholder="Type a message..."
></textarea>
<div v-if="form.errors.content" class="c-chat__error">
{{ form.errors.content }}
</div>
<!-- Attachment Button & Hidden Input -->
<div class="c-chat__attachment-container">
<button
type="button"
@click="fileInput?.click()"
class="c-chat__attach-btn"
>
<Paperclip class="c-chat__attach-icon" />
Attach Photos/Videos
</button>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,video/*"
class="hidden"
@change="handleFileChange"
/>
</div>
<!-- Previews List -->
<div v-if="form.media.length > 0" class="c-chat__preview-list">
<div
v-for="(file, index) in form.media"
:key="index"
class="c-chat__preview-item"
>
<span class="c-chat__preview-name">{{
file.name
}}</span>
<button
type="button"
@click="removeFile(index)"
class="c-chat__preview-remove"
>
</button>
</div>
</div>
</div> </div>
<div class="flex items-center gap-4"> <div class="c-chat__submit-box">
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150"> <button
type="submit"
:disabled="form.processing"
class="c-chat__button"
>
Send Send
</button> </button>
</div> </div>
</form> </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>
<img
v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl"
class="c-chat__lightbox-image"
/>
<video
v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl"
controls
autoplay
class="c-chat__lightbox-video"
></video>
</div>
</div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-chat {
@apply mt-8;
}
.c-chat__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-chat__list {
@apply mt-4 space-y-4;
}
.c-chat__message {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-chat__message-header {
@apply flex justify-between;
}
.c-chat__message-author {
@apply font-semibold;
}
.c-chat__message-time {
@apply text-xs text-gray-500;
}
.c-chat__message-text {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-chat__message-media {
@apply mt-3 flex flex-wrap gap-2;
}
.c-chat__media-item {
@apply relative max-w-[240px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-chat__image {
@apply h-auto max-h-[180px] w-full object-cover;
}
.c-chat__video {
@apply h-auto max-h-[180px] w-full;
}
.c-chat__play-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-chat__empty {
@apply text-gray-500;
}
.c-chat__form {
@apply mt-6 space-y-6;
}
.c-chat__form-group {
@apply space-y-2;
}
.c-chat__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-chat__textarea {
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-chat__error {
@apply text-sm text-red-600;
}
.c-chat__attachment-container {
@apply mt-2 flex items-center;
}
.c-chat__attach-btn {
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs text-neutral-500 transition-colors hover:text-foreground;
}
.c-chat__attach-icon {
@apply size-3.5;
}
.c-chat__preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-chat__preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-chat__preview-name {
@apply max-w-[150px] truncate;
}
.c-chat__preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-chat__submit-box {
@apply flex items-center gap-4;
}
.c-chat__button {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
/* Lightbox Styling */
.c-chat__lightbox {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
}
.c-chat__lightbox-close {
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
}
.c-chat__lightbox-content {
@apply max-h-full max-w-full;
}
.c-chat__lightbox-image {
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
}
.c-chat__lightbox-video {
@apply max-h-[90vh] max-w-full rounded shadow-lg;
}
</style>

View File

@ -11,18 +11,36 @@ withDefaults(defineProps<Props>(), {
</script> </script>
<template> <template>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'"> <header :class="['c-heading', { 'c-heading--small': variant === 'small' }]">
<h2 <h2 class="c-heading__title">
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
"
>
{{ title }} {{ title }}
</h2> </h2>
<p v-if="description" class="text-sm text-muted-foreground"> <p v-if="description" class="c-heading__description">
{{ description }} {{ description }}
</p> </p>
</header> </header>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-heading {
@apply mb-8 space-y-0.5;
}
.c-heading--small {
@apply mb-0 space-y-0;
}
.c-heading__title {
@apply text-xl font-semibold tracking-tight;
}
.c-heading--small .c-heading__title {
@apply mb-0.5 text-base font-medium tracking-normal;
}
.c-heading__description {
@apply text-sm text-muted-foreground;
}
</style>

View File

@ -5,9 +5,17 @@ defineProps<{
</script> </script>
<template> <template>
<div v-show="message"> <div v-show="message" class="c-input-error">
<p class="text-sm text-red-600 dark:text-red-500"> <p class="c-input-error__message">
{{ message }} {{ message }}
</p> </p>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-input-error__message {
@apply text-sm text-red-600 dark:text-red-500;
}
</style>

View File

@ -21,26 +21,42 @@ defineExpose({
</script> </script>
<template> <template>
<div class="relative"> <div class="password-input">
<Input <Input
ref="inputRef" ref="inputRef"
:type="showPassword ? 'text' : 'password'" :type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)" :class="cn('password-input__field', props.class)"
v-bind="$attrs" v-bind="$attrs"
/> />
<button <button
type="button" type="button"
@click="showPassword = !showPassword" @click="showPassword = !showPassword"
:class=" class="password-input__toggle"
cn(
'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',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'" :aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1" :tabindex="-1"
> >
<EyeOff v-if="showPassword" class="size-4" /> <EyeOff v-if="showPassword" class="password-input__icon" />
<Eye v-else class="size-4" /> <Eye v-else class="password-input__icon" />
</button> </button>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.password-input {
@apply relative;
}
:deep(.password-input__field) {
@apply pr-10;
}
.password-input__toggle {
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none;
}
.password-input__icon {
@apply size-4;
}
</style>

View File

@ -18,8 +18,16 @@ defineProps<Props>();
:tabindex="tabindex" :tabindex="tabindex"
:method="method" :method="method"
:as="as" :as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500" class="c-text-link"
> >
<slot /> <slot />
</Link> </Link>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-text-link {
@apply text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500;
}
</style>

View File

@ -22,17 +22,35 @@ const showAvatar = computed(
</script> </script>
<template> <template>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg"> <Avatar class="user-info__avatar">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" /> <AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white"> <AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }} {{ getInitials(user.name) }}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="user-info__details">
<span class="truncate font-medium">{{ user.name }}</span> <span class="user-info__name">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{ <span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
user.email
}}</span>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.user-info__avatar {
@apply h-8 w-8 overflow-hidden rounded-lg;
}
.user-info__details {
@apply grid flex-1 text-left text-sm leading-tight;
}
.user-info__name {
@apply truncate font-medium;
}
.user-info__email {
@apply truncate text-xs text-muted-foreground;
}
</style>

View File

@ -10,28 +10,19 @@ defineProps<{
</script> </script>
<template> <template>
<div <div class="auth-layout">
class="flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10" <div class="auth-layout__container">
> <div class="auth-layout__inner">
<div class="w-full max-w-sm"> <div class="auth-layout__header">
<div class="flex flex-col gap-8"> <Link :href="home()" class="auth-layout__logo-link">
<div class="flex flex-col items-center gap-4"> <div class="auth-layout__logo-box">
<Link <AppLogoIcon class="auth-layout__logo" />
:href="home()"
class="flex flex-col items-center gap-2 font-medium"
>
<div
class="mb-1 flex h-9 w-9 items-center justify-center rounded-md"
>
<AppLogoIcon
class="size-9 fill-current text-[var(--foreground)] dark:text-white"
/>
</div> </div>
<span class="sr-only">{{ title }}</span> <span class="sr-only">{{ title }}</span>
</Link> </Link>
<div class="space-y-2 text-center"> <div class="auth-layout__title-box">
<h1 class="text-xl font-medium">{{ title }}</h1> <h1 class="auth-layout__title">{{ title }}</h1>
<p class="text-center text-sm text-muted-foreground"> <p class="auth-layout__description">
{{ description }} {{ description }}
</p> </p>
</div> </div>
@ -41,3 +32,47 @@ defineProps<{
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.auth-layout {
@apply flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10;
}
.auth-layout__container {
@apply w-full max-w-sm;
}
.auth-layout__inner {
@apply flex flex-col gap-8;
}
.auth-layout__header {
@apply flex flex-col items-center gap-4;
}
.auth-layout__logo-link {
@apply flex flex-col items-center gap-2 font-medium;
}
.auth-layout__logo-box {
@apply mb-1 flex h-9 w-9 items-center justify-center rounded-md;
}
.auth-layout__logo {
@apply size-9 fill-current text-[var(--foreground)] dark:text-white;
}
.auth-layout__title-box {
@apply space-y-2 text-center;
}
.auth-layout__title {
@apply text-xl font-medium;
}
.auth-layout__description {
@apply text-center text-sm text-muted-foreground;
}
</style>

View File

@ -26,33 +26,67 @@ function submit() {
<template> <template>
<Head title="Create Dynamic" /> <Head title="Create Dynamic" />
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div
<div class="p-6 text-gray-900 dark:text-gray-100"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<h3 class="text-lg font-medium">Create a New Dynamic</h3> >
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Dynamic</h3>
<form @submit.prevent="submit" class="mt-6 space-y-6"> <form @submit.prevent="submit" class="mt-6 space-y-6">
<div> <div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label> <label
<input v-model="form.name" id="name" type="text" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm" /> for="name"
<div v-if="form.errors.name" class="text-sm text-red-600">{{ form.errors.name }}</div> class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Name</label
>
<input
v-model="form.name"
id="name"
type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
/>
<div
v-if="form.errors.name"
class="text-sm text-red-600"
>
{{ form.errors.name }}
</div> </div>
</div>
<div> <div>
<label for="rules" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Rules</label> <label
<textarea v-model="form.rules" id="rules" rows="4" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea> for="rules"
<div v-if="form.errors.rules" class="text-sm text-red-600">{{ form.errors.rules }}</div> class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label
>
<textarea
v-model="form.rules"
id="rules"
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
></textarea>
<div
v-if="form.errors.rules"
class="text-sm text-red-600"
>
{{ form.errors.rules }}
</div> </div>
</div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150"> <button
Create type="submit"
</button> :disabled="form.processing"
</div> class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
</form> >
</div> Create
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@ -17,30 +17,48 @@ const breadcrumbs = [
<template> <template>
<Head title="Dynamics" /> <Head title="Dynamics" />
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div
<div class="p-6 text-gray-900 dark:text-gray-100"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<div class="flex justify-between items-center mb-6"> >
<h3 class="text-lg font-medium">Your Dynamics</h3> <div class="p-6 text-gray-900 dark:text-gray-100">
<Link :href="route('dynamics.create')" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150"> <div class="mb-6 flex items-center justify-between">
Create Dynamic <h3 class="text-lg font-medium">Your Dynamics</h3>
</Link> <Link
</div> :href="route('dynamics.create')"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
>
Create Dynamic
</Link>
</div>
<div v-if="dynamics.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div
<div v-for="dynamic in dynamics" :key="dynamic.id" class="p-6 bg-white dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600"> v-if="dynamics.length > 0"
<Link :href="route('dynamics.show', dynamic.id)"> class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
<h4 class="text-lg font-semibold">{{ dynamic.name }}</h4> >
</Link> <div
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ dynamic.rules }}</p> v-for="dynamic in dynamics"
</div> :key="dynamic.id"
</div> class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700"
<div v-else> >
<p>You don't have any dynamics yet.</p> <Link :href="route('dynamics.show', dynamic.id)">
<h4 class="text-lg font-semibold">
{{ dynamic.name }}
</h4>
</Link>
<p
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
{{ dynamic.rules }}
</p>
</div> </div>
</div> </div>
<div v-else>
<p>You don't have any dynamics yet.</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@ -1,11 +1,23 @@
<script setup> <script setup lang="ts">
import Chat from '@/components/Chat.vue'; import Chat from '@/components/Chat.vue';
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
const props = defineProps({ const props = defineProps<{
dynamic: Object, dynamic: {
}); id: number;
name: string;
rules: string;
chat: any;
participants: Array<{ id: number; name: string }>;
ledgers: Array<{
id: number;
name: string;
score: number;
media?: Array<{ id: number; url: string; mime_type: string }>;
}>;
};
}>();
const breadcrumbs = [ const breadcrumbs = [
{ {
@ -21,8 +33,22 @@ const breadcrumbs = [
const form = useForm({ const form = useForm({
name: '', name: '',
rules: '', rules: '',
media: [] as File[],
}); });
function handleLedgerFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeLedgerFile(index: number) {
form.media.splice(index, 1);
}
function submit() { function submit() {
form.post(route('dynamics.ledgers.store', props.dynamic.id), { form.post(route('dynamics.ledgers.store', props.dynamic.id), {
onSuccess: () => form.reset(), onSuccess: () => form.reset(),
@ -33,70 +59,207 @@ function submit() {
<template> <template>
<Head :title="dynamic.name" /> <Head :title="dynamic.name" />
<div class="py-12"> <div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> <div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">{{ dynamic.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ dynamic.rules }}
</p>
</div>
</div>
<Chat :chat="dynamic.chat" />
<div class="mt-8">
<h4
class="text-lg font-medium text-gray-900 dark:text-gray-100"
>
Participants
</h4>
<ul
class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<li
v-for="participant in dynamic.participants"
:key="participant.id"
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
>
{{ participant.name }}
</li>
</ul>
</div>
<div class="mt-8">
<div class="mb-6 flex items-center justify-between">
<h4
class="text-lg font-medium text-gray-900 dark:text-gray-100"
>
Ledgers
</h4>
</div>
<ul
class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
>
<li
v-for="ledger in dynamic.ledgers"
:key="ledger.id"
class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700"
>
<Link
:href="
route('dynamics.ledgers.show', {
dynamic: dynamic.id,
ledger: ledger.id,
})
"
>
<h5 class="text-lg font-semibold">
{{ ledger.name }}
</h5>
<p
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
Score: {{ ledger.score }}
</p>
<!-- Ledger Media Thumbnails -->
<div
v-if="ledger.media && ledger.media.length > 0"
class="mt-2 flex flex-wrap gap-1"
>
<div
v-for="item in ledger.media"
:key="item.id"
class="relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600"
>
<img
v-if="
item.mime_type.startsWith('image/')
"
:src="item.url"
class="size-full object-cover"
/>
<video
v-else-if="
item.mime_type.startsWith('video/')
"
:src="item.url"
class="size-full object-cover"
/>
</div>
</div>
</Link>
</li>
</ul>
<div
v-if="dynamic.ledgers.length === 0"
class="mt-4 text-gray-500"
>
No ledgers found for this dynamic.
</div>
</div>
<div class="mt-8">
<div
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="p-6 text-gray-900 dark:text-gray-100"> <div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">{{ dynamic.name }}</h3> <h3 class="text-lg font-medium">Create a New Ledger</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ dynamic.rules }}</p>
</div>
</div>
<Chat :chat="dynamic.chat" /> <form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<div class="mt-8"> <label
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Participants</h4> for="name"
<ul class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> class="block text-sm font-medium text-gray-700 dark:text-gray-300"
<li v-for="participant in dynamic.participants" :key="participant.id" class="p-4 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> >Name</label
{{ participant.name }} >
</li> <input
</ul> v-model="form.name"
</div> id="name"
type="text"
<div class="mt-8"> class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
<div class="flex justify-between items-center mb-6"> />
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Ledgers</h4> <div
</div> v-if="form.errors.name"
<ul class="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> class="text-sm text-red-600"
<li v-for="ledger in dynamic.ledgers" :key="ledger.id" class="p-6 bg-white dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600"> >
<Link :href="route('dynamics.ledgers.show', { dynamic: dynamic.id, ledger: ledger.id })"> {{ form.errors.name }}
<h5 class="text-lg font-semibold">{{ ledger.name }}</h5>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Score: {{ ledger.score }}</p>
</Link>
</li>
</ul>
<div v-if="dynamic.ledgers.length === 0" class="mt-4 text-gray-500">
No ledgers found for this dynamic.
</div>
</div>
<div class="mt-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Ledger</h3>
<form @submit.prevent="submit" class="mt-6 space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input v-model="form.name" id="name" type="text" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm" />
<div v-if="form.errors.name" class="text-sm text-red-600">{{ form.errors.name }}</div>
</div> </div>
</div>
<div> <div>
<label for="rules" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Rules</label> <label
<textarea v-model="form.rules" id="rules" rows="4" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea> for="rules"
<div v-if="form.errors.rules" class="text-sm text-red-600">{{ form.errors.rules }}</div> class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label
>
<textarea
v-model="form.rules"
id="rules"
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
></textarea>
<div
v-if="form.errors.rules"
class="text-sm text-red-600"
>
{{ form.errors.rules }}
</div> </div>
</div>
<div class="flex items-center gap-4"> <!-- Media Uploads for Ledgers -->
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150"> <div>
Create Ledger <label
</button> class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Cover/Rules Media</label
>
<input
type="file"
multiple
accept="image/*,video/*"
@change="handleLedgerFileChange"
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
/>
<div
v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2"
>
<div
v-for="(file, index) in form.media"
:key="index"
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800"
>
<span class="max-w-[150px] truncate">{{
file.name
}}</span>
<button
type="button"
@click="removeLedgerFile(index)"
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
>
</button>
</div>
</div> </div>
</form> </div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
>
Create Ledger
</button>
</div>
</form>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@ -1,12 +1,41 @@
<script setup> <script setup lang="ts">
import { ref } from 'vue';
import Chat from '@/components/Chat.vue'; import Chat from '@/components/Chat.vue';
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { route } from 'ziggy-js'; import { route } from 'ziggy-js';
import { useEcho } from '@laravel/echo-vue';
const props = defineProps({ const props = defineProps<{
dynamic: Object, dynamic: {
ledger: Object, id: number;
}); name: string;
chat: { id: number };
participants?: Array<{
id: number;
name: string;
pivot?: { role: string };
}>;
};
ledger: {
id: number;
name: string;
score: number;
rules: string;
media?: Array<{ id: number; url: string; mime_type: string }>;
mutations: Array<{
id: number;
user_id: number;
user: { name: string };
amount: number;
description: string;
status: string;
created_at: string;
chat: any;
media?: Array<{ id: number; url: string; mime_type: string }>;
}>;
};
isOwner: boolean;
}>();
const breadcrumbs = [ const breadcrumbs = [
{ {
@ -19,78 +48,460 @@ const breadcrumbs = [
}, },
{ {
name: props.ledger.name, name: props.ledger.name,
href: route('dynamics.ledgers.show', { dynamic: props.dynamic.id, ledger: props.ledger.id }), href: route('dynamics.ledgers.show', {
dynamic: props.dynamic.id,
ledger: props.ledger.id,
}),
}, },
]; ];
const form = useForm({ const form = useForm({
amount: 0, amount: 0,
description: '', description: '',
media: [] as File[],
}); });
function handleMutationFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files) {
for (let i = 0; i < files.length; i++) {
form.media.push(files[i]);
}
}
}
function removeMutationFile(index: number) {
form.media.splice(index, 1);
}
function submit() { function submit() {
form.post(route('dynamics.ledgers.mutations.store', { dynamic: props.dynamic.id, ledger: props.ledger.id }), { form.post(
onSuccess: () => form.reset(), route('dynamics.ledgers.mutations.store', {
}); dynamic: props.dynamic.id,
ledger: props.ledger.id,
}),
{
onSuccess: () => form.reset(),
},
);
}
// Lightbox Modal state
const activeLightboxUrl = ref<string | null>(null);
const activeLightboxType = ref<'image' | 'video' | null>(null);
function openLightbox(url: string, mimeType: string) {
activeLightboxUrl.value = url;
activeLightboxType.value = mimeType.startsWith('image/')
? 'image'
: 'video';
}
function closeLightbox() {
activeLightboxUrl.value = null;
activeLightboxType.value = null;
}
// Toast System
const toasts = ref<Array<{ id: number; message: string }>>([]);
let toastCount = 0;
function showToast(message: string) {
const id = toastCount++;
toasts.value.push({ id, message });
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id);
}, 6000);
}
// Real-time Mutation Event Listeners
useEcho(`chats.${props.dynamic.chat.id}`, 'MutationCreated', (e: any) => {
if (e.mutation.ledger_id === props.ledger.id) {
// Prevent duplicate mutations
if (!props.ledger.mutations.some((m) => m.id === e.mutation.id)) {
props.ledger.mutations.unshift(e.mutation);
}
// Auto-update score if already approved (e.g. submitted by owner)
if (e.mutation.status === 'approved') {
props.ledger.score += e.mutation.amount;
}
showToast(
`New ${e.mutation.status} ledger entry added by ${e.mutation.user.name}: "${e.mutation.description}"`,
);
}
});
useEcho(`chats.${props.dynamic.chat.id}`, 'MutationUpdated', (e: any) => {
if (e.mutation.ledger_id === props.ledger.id) {
const index = props.ledger.mutations.findIndex(
(m) => m.id === e.mutation.id,
);
if (index !== -1) {
const oldStatus = props.ledger.mutations[index].status;
const newStatus = e.mutation.status;
// Update status in-place
props.ledger.mutations[index].status = newStatus;
// Transition ledger score based on status update
if (oldStatus !== 'approved' && newStatus === 'approved') {
props.ledger.score += e.mutation.amount;
} else if (oldStatus === 'approved' && newStatus !== 'approved') {
props.ledger.score -= e.mutation.amount;
}
showToast(
`Chore entry "${e.mutation.description}" status updated to ${newStatus.toUpperCase()}`,
);
}
}
});
function updateStatus(mutationId: number, status: 'approved' | 'rejected') {
useForm({ status }).put(
route('dynamics.ledgers.mutations.update', {
dynamic: props.dynamic.id,
ledger: props.ledger.id,
mutation: mutationId,
}),
);
}
// Check if user is an owner in the dynamic
function isOwnerUser(userId: number): boolean {
const participant = props.dynamic.participants?.find(
(p) => p.id === userId,
);
return participant?.pivot?.role === 'owner';
} }
</script> </script>
<template> <template>
<Head :title="ledger.name" /> <Head :title="ledger.name" />
<div class="py-12"> <!-- Floating Toast Notifications -->
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> class="pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3"
<div class="p-6 text-gray-900 dark:text-gray-100"> >
<h3 class="text-lg font-medium">{{ ledger.name }}</h3> <div
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Score: {{ ledger.score }}</p> v-for="toast in toasts"
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ ledger.rules }}</p> :key="toast.id"
</div> class="pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl"
</div> >
<span>{{ toast.message }}</span>
<button
@click="toasts = toasts.filter((t) => t.id !== toast.id)"
class="cursor-pointer text-neutral-400 hover:text-white"
>
</button>
</div>
</div>
<div class="mt-8"> <div class="py-12">
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Add Mutation</h4> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<form @submit.prevent="submit" class="mt-6 space-y-6 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg p-6"> <div
<div> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Amount</label> >
<input v-model="form.amount" id="amount" type="number" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm" /> <div class="p-6 text-gray-900 dark:text-gray-100">
<div v-if="form.errors.amount" class="text-sm text-red-600">{{ form.errors.amount }}</div> <h3 class="text-lg font-medium">{{ ledger.name }}</h3>
</div> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Score: {{ ledger.score }}
</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ ledger.rules }}
</p>
<div> <!-- Ledger Descriptive Media -->
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label> <div
<textarea v-model="form.description" id="description" rows="4" class="mt-1 block w-full border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm"></textarea> v-if="ledger.media && ledger.media.length > 0"
<div v-if="form.errors.description" class="text-sm text-red-600">{{ form.errors.description }}</div> class="mt-4 flex flex-wrap gap-3"
</div> >
<div
<div class="flex items-center gap-4"> v-for="item in ledger.media"
<button type="submit" :disabled="form.processing" class="inline-flex items-center px-4 py-2 bg-gray-800 dark:bg-gray-200 border border-transparent rounded-md font-semibold text-xs text-white dark:text-gray-800 uppercase tracking-widest hover:bg-gray-700 dark:hover:bg-white focus:bg-gray-700 dark:focus:bg-white active:bg-gray-900 dark:active:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition ease-in-out duration-150"> :key="item.id"
Add Mutation class="max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
</button> >
</div> <img
</form> v-if="item.mime_type.startsWith('image/')"
</div> :src="item.url"
class="h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
<div class="mt-8"> @click="openLightbox(item.url, item.mime_type)"
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">Mutations</h4> />
<ul class="mt-4 space-y-4"> <div
<li v-for="mutation in ledger.mutations" :key="mutation.id" class="p-4 bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg"> v-else-if="item.mime_type.startsWith('video/')"
<div class="flex justify-between"> class="relative cursor-pointer transition-opacity hover:opacity-90"
<span class="font-semibold">{{ mutation.user.name }}</span> @click="openLightbox(item.url, item.mime_type)"
<span :class="{ >
'text-green-500': mutation.amount > 0, <video
'text-red-500': mutation.amount < 0, :src="item.url"
}">{{ mutation.amount > 0 ? '+' : '' }}{{ mutation.amount }}</span> class="h-auto max-h-[200px] w-full"
></video>
<div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div>
</div> </div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ mutation.description }}</p> </div>
<div class="mt-2 text-xs text-gray-500">{{ new Date(mutation.created_at).toLocaleString() }}</div>
<Chat :chat="mutation.chat" />
</li>
</ul>
<div v-if="ledger.mutations.length === 0" class="mt-4 text-gray-500">
No mutations found for this ledger.
</div> </div>
</div> </div>
</div> </div>
<div class="mt-8">
<h4
class="text-lg font-medium text-gray-900 dark:text-gray-100"
>
Add Mutation
</h4>
<form
@submit.prevent="submit"
class="mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div>
<label
for="amount"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Amount</label
>
<input
v-model="form.amount"
id="amount"
type="number"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
/>
<div
v-if="form.errors.amount"
class="text-sm text-red-600"
>
{{ form.errors.amount }}
</div>
</div>
<div>
<label
for="description"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Description</label
>
<textarea
v-model="form.description"
id="description"
rows="4"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
></textarea>
<div
v-if="form.errors.description"
class="text-sm text-red-600"
>
{{ form.errors.description }}
</div>
</div>
<!-- Media Uploads for Mutations -->
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Proof Media (Photos/Videos)</label
>
<input
type="file"
multiple
accept="image/*,video/*"
@change="handleMutationFileChange"
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
/>
<div
v-if="form.media.length > 0"
class="mt-2 flex flex-wrap gap-2"
>
<div
v-for="(file, index) in form.media"
:key="index"
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800"
>
<span class="max-w-[150px] truncate">{{
file.name
}}</span>
<button
type="button"
@click="removeMutationFile(index)"
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
>
</button>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="form.processing"
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
>
Add Mutation
</button>
</div>
</form>
</div>
<div class="mt-8">
<h4
class="text-lg font-medium text-gray-900 dark:text-gray-100"
>
Mutations
</h4>
<ul class="mt-4 space-y-4">
<li
v-for="mutation in ledger.mutations"
:key="mutation.id"
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
>
<div class="flex items-start justify-between">
<div>
<span class="font-semibold">{{
mutation.user.name
}}</span>
<div class="mt-1 flex items-center gap-2">
<span
:class="{
'text-green-500':
mutation.amount > 0,
'text-red-500': mutation.amount < 0,
}"
class="text-sm font-bold"
>{{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }}</span
>
<!-- Only show status badge if mutation was NOT auto-approved by an owner -->
<span
v-if="!isOwnerUser(mutation.user_id)"
:class="{
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400':
mutation.status === 'pending',
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400':
mutation.status === 'approved',
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400':
mutation.status === 'rejected',
}"
class="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
>
{{ mutation.status }}
</span>
</div>
</div>
<div class="text-xs text-gray-500">
{{
new Date(
mutation.created_at,
).toLocaleString()
}}
</div>
</div>
<p
class="mt-3 text-sm text-gray-600 dark:text-gray-400"
>
{{ mutation.description }}
</p>
<!-- Attached Mutation Proof Media -->
<div
v-if="mutation.media && mutation.media.length > 0"
class="mt-3 flex flex-wrap gap-2"
>
<div
v-for="item in mutation.media"
:key="item.id"
class="max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
>
<img
v-if="item.mime_type.startsWith('image/')"
:src="item.url"
class="h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
@click="
openLightbox(item.url, item.mime_type)
"
/>
<div
v-else-if="
item.mime_type.startsWith('video/')
"
class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="
openLightbox(item.url, item.mime_type)
"
>
<video
:src="item.url"
class="h-auto max-h-[150px] w-full"
></video>
<div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div>
</div>
</div>
</div>
<!-- Owner Approve/Reject Actions -->
<div
v-if="isOwner && mutation.status === 'pending'"
class="mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700"
>
<button
@click="updateStatus(mutation.id, 'approved')"
class="inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500"
>
Approve
</button>
<button
@click="updateStatus(mutation.id, 'rejected')"
class="inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500"
>
Reject
</button>
</div>
<Chat :chat="mutation.chat" />
</li>
</ul>
<div
v-if="ledger.mutations.length === 0"
class="mt-4 text-gray-500"
>
No mutations found for this ledger.
</div>
</div>
</div> </div>
</div>
<!-- Lightbox Modal -->
<div
v-if="activeLightboxUrl"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4"
@click="closeLightbox"
>
<button
@click="closeLightbox"
class="absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500"
>
</button>
<div class="max-h-full max-w-full" @click.stop>
<img
v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl"
class="max-h-[90vh] max-w-full rounded object-contain shadow-lg"
/>
<video
v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl"
controls
autoplay
class="max-h-[90vh] max-w-full rounded shadow-lg"
></video>
</div>
</div>
</template> </template>

View File

@ -7,6 +7,9 @@ import { bunny } from 'laravel-vite-plugin/fonts';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
resolve: {
dedupe: ['@laravel/echo-vue'],
},
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.ts'], input: ['resources/css/app.css', 'resources/js/app.ts'],