Compare commits

..

No commits in common. "a687d7ac4dc6d5d526edd35f4640b056e4628231" and "114d0f81a4dc6d88d7e060d0257692a493ddc468" have entirely different histories.

29 changed files with 198 additions and 1617 deletions

View File

@ -131,7 +131,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===

32
IDEA.md
View File

@ -9,35 +9,3 @@ Dynamics have a rules-segment too. Not all people within a dynamic can edit thes
The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future. The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future.
Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features. Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features.
---
## Session Developments & Achievements
During this session, we successfully built out and verified several core architectural features of the Ledgerrz application:
1. **BEM Styling Refactoring (BDSM Theme first)**:
* Replaced inline Tailwind classes across all core Vue pages and layout components (`Dynamics/Index.vue`, `Dynamics/Create.vue`, `Dynamics/Show.vue`, `Ledgers/Show.vue`, `ParticipantsList.vue`, `LedgerList.vue`, `CreateLedgerForm.vue`, `AddMutationForm.vue`, and `MutationList.vue`) with structured BEM scoped CSS blocks.
* Utilized Tailwind v4 `@apply` and `@reference` inside `<style scoped>` blocks to prevent code duplication, enforce a dark-first BDSM aesthetic, and maintain clear separation of concerns.
2. **Ledger Alignment & Scoring Logic**:
* Added an `alignment` column (`positive`, `neutral`, `negative`) to Ledgers, affecting the color coding of mutations:
* *Positive Alignment*: higher score is better; additions are green (+), demerits/deductions are red (-).
* *Negative Alignment*: lower score is better (demerits); deductions are green (-) as they reduce demerits, and additions/infractions are red (+) as they increase demerits.
* *Neutral Alignment*: standard scoring, styled in gray/neutral colors.
* Added an "Alignment" dropdown selector in the ledger creation form.
3. **Demerit Authorship Correction**:
* Corrected database seeders so demerits/penalty mutations are authored by the Dominant/Owner user (`alice` or `testUser`) rather than the submissive (`bob`), aligning with realistic power exchange dynamics.
4. **Read Cursor & Dashboard Activities Highlights**:
* Designed and built a polymorphic `read_cursors` table to track a user's last-viewed timestamp on individual Dynamics and Ledgers.
* Developed `ActivityService` to chronological sort and partition dynamic activity (messages, ledger creation) and ledger activity (mutations, approval updates, and comments) into unread vs. read categories.
* Highlighted unread entities grouped together on the user's dashboard, displaying the **last two already-read items as muted context** followed by highlighted unread activities with a prominent `NEW` badge.
* Visiting a Dynamic/Ledger automatically updates its read cursor, removing it from subsequent dashboard loads.
* Dynamically shared the unread entity count as a global Inertia prop, rendering a vibrant red dot notification badge over a bell icon in the top-right of the header.
5. **Broadcasts, Environment & Verification**:
* Configured real-time notifications utilizing Laravel Reverb.
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors.
* Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**.

View File

@ -1,20 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Services\ActivityService;
use Illuminate\Http\Request;
use Inertia\Inertia;
class DashboardController extends Controller
{
public function index(Request $request, ActivityService $activityService)
{
$user = $request->user();
$unreadEntities = $activityService->getUnreadEntitiesGrouped($user);
return Inertia::render('Dashboard', [
'unreadEntities' => $unreadEntities,
]);
}
}

View File

@ -8,8 +8,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use App\Services\ActivityService;
class DynamicController extends Controller class DynamicController extends Controller
{ {
use AuthorizesRequests; use AuthorizesRequests;
@ -46,12 +44,10 @@ class DynamicController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Dynamic $dynamic, ActivityService $activityService) public function show(Dynamic $dynamic)
{ {
$this->authorize('view', $dynamic); $this->authorize('view', $dynamic);
$activityService->updateCursor(auth()->user(), $dynamic);
$dynamic->load([ $dynamic->load([
'ledgers.media', 'ledgers.media',
'participants', 'participants',

View File

@ -5,7 +5,6 @@ namespace App\Http\Controllers;
use App\Http\Requests\StoreLedgerRequest; use App\Http\Requests\StoreLedgerRequest;
use App\Models\Dynamic; use App\Models\Dynamic;
use App\Models\Ledger; use App\Models\Ledger;
use App\Services\ActivityService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Inertia\Inertia; use Inertia\Inertia;
@ -54,12 +53,10 @@ class LedgerController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
*/ */
public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService) public function show(Request $request, Dynamic $dynamic, Ledger $ledger)
{ {
$this->authorize('view', $ledger); $this->authorize('view', $ledger);
$activityService->updateCursor($request->user(), $ledger);
$dynamic->load('chat', 'participants'); $dynamic->load('chat', 'participants');
$ledger->load([ $ledger->load([

View File

@ -42,14 +42,6 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(), 'user' => $request->user(),
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'unreadNotificationsCount' => function () use ($request) {
if (! $request->user()) {
return 0;
}
$service = app(\App\Services\ActivityService::class);
return count($service->getUnreadEntitiesGrouped($request->user()));
},
]; ];
} }
} }

View File

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

View File

@ -18,7 +18,6 @@ class Ledger extends Model
'name', 'name',
'rules', 'rules',
'score', 'score',
'alignment',
]; ];
public function dynamic(): BelongsTo public function dynamic(): BelongsTo

View File

@ -1,31 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class ReadCursor extends Model
{
protected $fillable = [
'user_id',
'cursorable_id',
'cursorable_type',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function cursorable(): MorphTo
{
return $this->morphTo();
}
}

View File

@ -44,11 +44,6 @@ class User extends Authenticatable implements PasskeyUser
return $this->hasMany(Mutation::class); return $this->hasMany(Mutation::class);
} }
public function readCursors()
{
return $this->hasMany(ReadCursor::class);
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *

View File

@ -1,233 +0,0 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Models\ReadCursor;
use Illuminate\Support\Carbon;
class ActivityService
{
/**
* Update the read cursor for a user on a specific entity.
*/
public function updateCursor(User $user, $entity): void
{
ReadCursor::updateOrCreate(
[
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
],
[
'read_at' => Carbon::now(),
]
);
}
/**
* Get the read cursor timestamp for a user on a specific entity.
*/
public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface
{
$cursor = ReadCursor::where([
'user_id' => $user->id,
'cursorable_id' => $entity->id,
'cursorable_type' => get_class($entity),
])->first();
// If no cursor exists, default to epoch (all activities are unread)
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
}
/**
* Retrieve all activities for a Dynamic.
*/
public function getDynamicActivities(Dynamic $dynamic): array
{
$activities = [];
// 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,
];
}
}
// 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,
];
}
// Sort activities chronologically ascending
usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp);
return $activities;
}
/**
* Retrieve all activities for a Ledger.
*/
public function getLedgerActivities(Ledger $ledger): array
{
$activities = [];
// 1. Mutations (Creation and Status updates)
$mutations = Mutation::where('ledger_id', $ledger->id)
->with('user')
->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;
}
/**
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
*/
public function getUnreadEntitiesGrouped(User $user): array
{
$groupedEntities = [];
// 1. Get all participating Dynamics
$dynamics = $user->dynamics()->get();
foreach ($dynamics as $dynamic) {
$readAt = $this->getCursorReadAt($user, $dynamic);
$activities = $this->getDynamicActivities($dynamic);
$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);
}
}
return $groupedEntities;
}
/**
* Partition activities into read and unread, and construct the grouped entity metadata.
*/
private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void
{
$alreadyRead = [];
$unread = [];
foreach ($activities as $act) {
if ($act['created_at']->gt($readAt)) {
$unread[] = $act;
} else {
$alreadyRead[] = $act;
}
}
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;
};
$groupedEntities[] = [
'id' => $entity->id,
'name' => $entity->name,
'type' => $type,
'url' => $url,
'unread_count' => count($unread),
'context_activities' => array_map($formatActivity, $context),
'new_activities' => array_map($formatActivity, $unread),
];
}
}
}

View File

@ -30,7 +30,6 @@ class LedgerFactory extends Factory
]), ]),
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.', 'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
'score' => 0, 'score' => 0,
'alignment' => 'neutral',
]; ];
} }
} }

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->string('alignment')->default('neutral')->after('rules');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ledgers', function (Blueprint $table) {
$table->dropColumn('alignment');
});
}
};

View File

@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('read_cursors', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->morphs('cursorable');
$table->timestamp('read_at');
$table->timestamps();
$table->unique(['user_id', 'cursorable_id', 'cursorable_type']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('read_cursors');
}
};

View File

@ -94,7 +94,6 @@ class DatabaseSeeder extends Seeder
'name' => 'Curfew Compliance', 'name' => 'Curfew Compliance',
'rules' => 'Must be in bed and checked in by 11:00 PM.', 'rules' => 'Must be in bed and checked in by 11:00 PM.',
'score' => 35, 'score' => 35,
'alignment' => 'positive',
]); ]);
$cleaningLedger = Ledger::create([ $cleaningLedger = Ledger::create([
@ -102,15 +101,13 @@ class DatabaseSeeder extends Seeder
'name' => 'Dungeon Cleaning', 'name' => 'Dungeon Cleaning',
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.', 'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
'score' => 45, 'score' => 45,
'alignment' => 'neutral',
]); ]);
$etiquetteLedger = Ledger::create([ $etiquetteLedger = Ledger::create([
'dynamic_id' => $velvetSanctuary->id, 'dynamic_id' => $velvetSanctuary->id,
'name' => 'Protocol & Etiquette', 'name' => 'Protocol & Etiquette',
'rules' => 'Address others by correct titles. +5 per infraction.', 'rules' => 'Address others by correct titles. -5 per infraction.',
'score' => 15, 'score' => -15,
'alignment' => 'negative',
]); ]);
// Seed Curfew Mutations // Seed Curfew Mutations
@ -171,7 +168,7 @@ class DatabaseSeeder extends Seeder
Mutation::create([ Mutation::create([
'ledger_id' => $cleaningLedger->id, 'ledger_id' => $cleaningLedger->id,
'user_id' => $alice->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => -10, 'amount' => -10,
'description' => 'Left keys in the locks unmonitored', 'description' => 'Left keys in the locks unmonitored',
@ -212,27 +209,27 @@ class DatabaseSeeder extends Seeder
// Seed Etiquette Mutations // Seed Etiquette Mutations
Mutation::create([ Mutation::create([
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $alice->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => 5, 'amount' => -5,
'description' => 'Interrupted Domina Alice during daily instructions', 'description' => 'Interrupted Domina Alice during daily instructions',
'status' => 'approved', 'status' => 'approved',
]); ]);
Mutation::create([ Mutation::create([
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $alice->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => 10, 'amount' => -10,
'description' => 'Forgot correct posture during morning roll call', 'description' => 'Forgot correct posture during morning roll call',
'status' => 'approved', 'status' => 'approved',
]); ]);
Mutation::create([ Mutation::create([
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $alice->id, 'user_id' => $bob->id,
'type' => 'penalty', 'type' => 'penalty',
'amount' => 5, 'amount' => -5,
'description' => 'Spoke out of turn in general chat', 'description' => 'Spoke out of turn in general chat',
'status' => 'approved', 'status' => 'approved',
]); ]);
@ -241,7 +238,7 @@ class DatabaseSeeder extends Seeder
'ledger_id' => $etiquetteLedger->id, 'ledger_id' => $etiquetteLedger->id,
'user_id' => $bob->id, 'user_id' => $bob->id,
'type' => 'reward', 'type' => 'reward',
'amount' => -5, 'amount' => 5,
'description' => 'Excellent reciting of the house codes', 'description' => 'Excellent reciting of the house codes',
'status' => 'approved', 'status' => 'approved',
]); ]);
@ -291,7 +288,6 @@ class DatabaseSeeder extends Seeder
'name' => 'Kitchen Chores', 'name' => 'Kitchen Chores',
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.', 'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
'score' => 40, 'score' => 40,
'alignment' => 'positive',
]); ]);
$coffeeLedger = Ledger::create([ $coffeeLedger = Ledger::create([
@ -299,7 +295,6 @@ class DatabaseSeeder extends Seeder
'name' => 'Coffee Machine Maintenance', 'name' => 'Coffee Machine Maintenance',
'rules' => ' refill beans and descale monthly.', 'rules' => ' refill beans and descale monthly.',
'score' => 10, 'score' => 10,
'alignment' => 'neutral',
]); ]);
// Seed Chores Mutations // Seed Chores Mutations

View File

@ -19,9 +19,7 @@ configureEcho({
forceTLS: false, forceTLS: false,
enabledTransports: ['ws', 'wss'], enabledTransports: ['ws', 'wss'],
}); });
if(window){ (window as any).echoConfigured = true;
(window as any).echoConfigured = true;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

View File

@ -41,55 +41,55 @@ function submit() {
</script> </script>
<template> <template>
<div class="c-add-mutation-form"> <div class="mt-8">
<h4 class="c-add-mutation-form__title"> <h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Add Mutation Add Mutation
</h4> </h4>
<form <form
@submit.prevent="submit" @submit.prevent="submit"
class="c-add-mutation-form__form" class="mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800"
> >
<div class="c-add-mutation-form__field"> <div>
<label <label
for="amount" for="amount"
class="c-add-mutation-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Amount</label >Amount</label
> >
<input <input
v-model="form.amount" v-model="form.amount"
id="amount" id="amount"
type="number" type="number"
class="c-add-mutation-form__input" 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="c-add-mutation-form__error"> <div v-if="form.errors.amount" class="text-sm text-red-600">
{{ form.errors.amount }} {{ form.errors.amount }}
</div> </div>
</div> </div>
<div class="c-add-mutation-form__field"> <div>
<label <label
for="description" for="description"
class="c-add-mutation-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Description</label >Description</label
> >
<textarea <textarea
v-model="form.description" v-model="form.description"
id="description" id="description"
rows="4" rows="4"
class="c-add-mutation-form__textarea" 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> ></textarea>
<div <div
v-if="form.errors.description" v-if="form.errors.description"
class="c-add-mutation-form__error" class="text-sm text-red-600"
> >
{{ form.errors.description }} {{ form.errors.description }}
</div> </div>
</div> </div>
<!-- Media Uploads for Mutations --> <!-- Media Uploads for Mutations -->
<div class="c-add-mutation-form__field"> <div>
<label <label
class="c-add-mutation-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Proof Media (Photos/Videos)</label >Attach Proof Media (Photos/Videos)</label
> >
<input <input
@ -97,24 +97,24 @@ function submit() {
multiple multiple
accept="image/*,video/*" accept="image/*,video/*"
@change="handleMutationFileChange" @change="handleMutationFileChange"
class="c-add-mutation-form__file-input" 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 <div
v-if="form.media.length > 0" v-if="form.media.length > 0"
class="c-add-mutation-form__media-preview-list" class="mt-2 flex flex-wrap gap-2"
> >
<div <div
v-for="(file, index) in form.media" v-for="(file, index) in form.media"
:key="index" :key="index"
class="c-add-mutation-form__media-preview-item" 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="c-add-mutation-form__media-preview-name">{{ <span class="max-w-[150px] truncate">{{
file.name file.name
}}</span> }}</span>
<button <button
type="button" type="button"
@click="removeMutationFile(index)" @click="removeMutationFile(index)"
class="c-add-mutation-form__media-preview-remove" class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
> >
</button> </button>
@ -122,11 +122,11 @@ function submit() {
</div> </div>
</div> </div>
<div class="c-add-mutation-form__actions"> <div class="flex items-center gap-4">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="c-add-mutation-form__submit-btn" 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 Add Mutation
</button> </button>
@ -134,67 +134,3 @@ function submit() {
</form> </form>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-add-mutation-form {
@apply mt-8;
}
.c-add-mutation-form__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-add-mutation-form__form {
@apply mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-add-mutation-form__field {
@apply block;
}
.c-add-mutation-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-add-mutation-form__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__textarea {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-add-mutation-form__file-input {
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
}
.c-add-mutation-form__media-preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-add-mutation-form__media-preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-add-mutation-form__media-preview-name {
@apply max-w-[150px] truncate;
}
.c-add-mutation-form__media-preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-add-mutation-form__error {
@apply text-sm text-red-600;
}
.c-add-mutation-form__actions {
@apply flex items-center gap-4;
}
.c-add-mutation-form__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

View File

@ -238,34 +238,6 @@ const rightNavItems: NavItem[] = [
</div> </div>
</div> </div>
<div
v-if="page.props.unreadNotificationsCount > 0"
class="relative"
>
<Link
:href="route('dashboard')"
class="relative flex h-9 w-9 items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 opacity-80"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<span class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white">
{{ page.props.unreadNotificationsCount }}
</span>
</Link>
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger :as-child="true"> <DropdownMenuTrigger :as-child="true">
<Button <Button

View File

@ -9,7 +9,6 @@ const props = defineProps<{
const form = useForm({ const form = useForm({
name: '', name: '',
rules: '', rules: '',
alignment: 'neutral',
media: [] as File[], media: [] as File[],
}); });
@ -35,79 +34,58 @@ function submit() {
</script> </script>
<template> <template>
<div class="c-create-ledger-form"> <div class="mt-8">
<div class="c-create-ledger-form__card"> <div
<div class="c-create-ledger-form__body"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3> >
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">Create a New Ledger</h3>
<form @submit.prevent="submit" class="c-create-ledger-form__form"> <form @submit.prevent="submit" class="mt-6 space-y-6">
<div class="c-create-ledger-form__field"> <div>
<label <label
for="name" for="name"
class="c-create-ledger-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Name</label >Name</label
> >
<input <input
v-model="form.name" v-model="form.name"
id="name" id="name"
type="text" type="text"
class="c-create-ledger-form__input" 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 <div
v-if="form.errors.name" v-if="form.errors.name"
class="c-create-ledger-form__error" class="text-sm text-red-600"
> >
{{ form.errors.name }} {{ form.errors.name }}
</div> </div>
</div> </div>
<div class="c-create-ledger-form__field"> <div>
<label <label
for="rules" for="rules"
class="c-create-ledger-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label >Rules</label
> >
<textarea <textarea
v-model="form.rules" v-model="form.rules"
id="rules" id="rules"
rows="4" rows="4"
class="c-create-ledger-form__textarea" 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> ></textarea>
<div <div
v-if="form.errors.rules" v-if="form.errors.rules"
class="c-create-ledger-form__error" class="text-sm text-red-600"
> >
{{ form.errors.rules }} {{ form.errors.rules }}
</div> </div>
</div> </div>
<div class="c-create-ledger-form__field">
<label
for="alignment"
class="c-create-ledger-form__label"
>Alignment</label
>
<select
v-model="form.alignment"
id="alignment"
class="c-create-ledger-form__select"
>
<option value="positive">Positive (Higher Score is Better)</option>
<option value="neutral">Neutral (Frictionless / Standard)</option>
<option value="negative">Negative (Lower Score is Better / Demerits)</option>
</select>
<div
v-if="form.errors.alignment"
class="c-create-ledger-form__error"
>
{{ form.errors.alignment }}
</div>
</div>
<!-- Media Uploads for Ledgers --> <!-- Media Uploads for Ledgers -->
<div class="c-create-ledger-form__field"> <div>
<label <label
class="c-create-ledger-form__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Attach Cover/Rules Media</label >Attach Cover/Rules Media</label
> >
<input <input
@ -115,24 +93,24 @@ function submit() {
multiple multiple
accept="image/*,video/*" accept="image/*,video/*"
@change="handleLedgerFileChange" @change="handleLedgerFileChange"
class="c-create-ledger-form__file-input" 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 <div
v-if="form.media.length > 0" v-if="form.media.length > 0"
class="c-create-ledger-form__media-preview-list" class="mt-2 flex flex-wrap gap-2"
> >
<div <div
v-for="(file, index) in form.media" v-for="(file, index) in form.media"
:key="index" :key="index"
class="c-create-ledger-form__media-preview-item" 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="c-create-ledger-form__media-preview-name">{{ <span class="max-w-[150px] truncate">{{
file.name file.name
}}</span> }}</span>
<button <button
type="button" type="button"
@click="removeLedgerFile(index)" @click="removeLedgerFile(index)"
class="c-create-ledger-form__media-preview-remove" class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
> >
</button> </button>
@ -140,11 +118,11 @@ function submit() {
</div> </div>
</div> </div>
<div class="c-create-ledger-form__actions"> <div class="flex items-center gap-4">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="c-create-ledger-form__submit-btn" 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 Create Ledger
</button> </button>
@ -154,79 +132,3 @@ function submit() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-create-ledger-form {
@apply mt-8;
}
.c-create-ledger-form__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-create-ledger-form__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-create-ledger-form__title {
@apply text-lg font-medium;
}
.c-create-ledger-form__form {
@apply mt-6 space-y-6;
}
.c-create-ledger-form__field {
@apply block;
}
.c-create-ledger-form__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.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;
}
.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;
}
.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;
}
.c-create-ledger-form__file-input {
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
}
.c-create-ledger-form__media-preview-list {
@apply mt-2 flex flex-wrap gap-2;
}
.c-create-ledger-form__media-preview-item {
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
}
.c-create-ledger-form__media-preview-name {
@apply max-w-[150px] truncate;
}
.c-create-ledger-form__media-preview-remove {
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
}
.c-create-ledger-form__error {
@apply text-sm text-red-600;
}
.c-create-ledger-form__actions {
@apply flex items-center gap-4;
}
.c-create-ledger-form__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

View File

@ -8,24 +8,23 @@ defineProps<{
id: number; id: number;
name: string; name: string;
score: number; score: number;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
}>; }>;
}>(); }>();
</script> </script>
<template> <template>
<div class="c-ledger-list"> <div class="mt-8">
<div class="c-ledger-list__header"> <div class="mb-6 flex items-center justify-between">
<h4 class="c-ledger-list__title"> <h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Ledgers Ledgers
</h4> </h4>
</div> </div>
<ul class="c-ledger-list__grid"> <ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<li <li
v-for="ledger in ledgers" v-for="ledger in ledgers"
:key="ledger.id" :key="ledger.id"
class="c-ledger-list__item" class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700"
> >
<Link <Link
:href=" :href="
@ -35,134 +34,40 @@ defineProps<{
}) })
" "
> >
<h5 class="c-ledger-list__item-name"> <h5 class="text-lg font-semibold">
{{ ledger.name }} {{ ledger.name }}
</h5> </h5>
<p class="c-ledger-list__item-score"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Score: {{ ledger.score }} Score: {{ ledger.score }}
</p> </p>
<!-- Ledger Alignment Badge -->
<div class="c-ledger-list__alignment-wrapper">
<span
v-if="ledger.alignment === 'positive'"
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--positive"
>
Higher is Better
</span>
<span
v-else-if="ledger.alignment === 'negative'"
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--negative"
>
Lower is Better
</span>
<span
v-else
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--neutral"
>
Neutral
</span>
</div>
<!-- Ledger Media Thumbnails --> <!-- Ledger Media Thumbnails -->
<div <div
v-if="ledger.media && ledger.media.length > 0" v-if="ledger.media && ledger.media.length > 0"
class="c-ledger-list__media-list" class="mt-2 flex flex-wrap gap-1"
> >
<div <div
v-for="item in ledger.media" v-for="item in ledger.media"
:key="item.id" :key="item.id"
class="c-ledger-list__media-item" class="relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="c-ledger-list__media-img" class="size-full object-cover"
/> />
<video <video
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
:src="item.url" :src="item.url"
class="c-ledger-list__media-video" class="size-full object-cover"
/> />
</div> </div>
</div> </div>
</Link> </Link>
</li> </li>
</ul> </ul>
<div v-if="ledgers.length === 0" class="c-ledger-list__empty"> <div v-if="ledgers.length === 0" class="mt-4 text-gray-500">
No ledgers found for this dynamic. No ledgers found for this dynamic.
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-ledger-list {
@apply mt-8;
}
.c-ledger-list__header {
@apply mb-6 flex items-center justify-between;
}
.c-ledger-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-ledger-list__grid {
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
}
.c-ledger-list__item {
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700;
}
.c-ledger-list__item-name {
@apply text-lg font-semibold;
}
.c-ledger-list__item-score {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-ledger-list__alignment-wrapper {
@apply mt-2 flex items-center;
}
.c-ledger-list__alignment-badge {
@apply text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded;
}
.c-ledger-list__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20;
}
.c-ledger-list__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20;
}
.c-ledger-list__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20;
}
.c-ledger-list__media-list {
@apply mt-2 flex flex-wrap gap-1;
}
.c-ledger-list__media-item {
@apply relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600;
}
.c-ledger-list__media-img {
@apply size-full object-cover;
}
.c-ledger-list__media-video {
@apply size-full object-cover;
}
.c-ledger-list__empty {
@apply mt-4 text-gray-500;
}
</style>

View File

@ -6,7 +6,6 @@ import Chat from '@/components/Chat.vue';
const props = defineProps<{ const props = defineProps<{
dynamicId: number; dynamicId: number;
ledgerId: number; ledgerId: number;
ledgerAlignment?: string;
mutations: Array<{ mutations: Array<{
id: number; id: number;
user_id: number; user_id: number;
@ -45,38 +44,22 @@ function isOwnerUser(userId: number): boolean {
return participant?.pivot?.role === 'owner'; return participant?.pivot?.role === 'owner';
} }
function getAmountClass(amount: number): string {
const alignment = props.ledgerAlignment || 'neutral';
if (alignment === 'positive') {
return amount > 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative';
}
if (alignment === 'negative') {
// Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable
return amount < 0 ? 'c-mutation-list__item-amount--positive' : 'c-mutation-list__item-amount--negative';
}
// Neutral alignment
return 'c-mutation-list__item-amount--neutral';
}
</script> </script>
<template> <template>
<div class="c-mutation-list"> <div class="mt-8">
<h4 class="c-mutation-list__title"> <h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Mutations Mutations
</h4> </h4>
<ul class="c-mutation-list__list"> <ul class="mt-4 space-y-4">
<li <li
v-for="mutation in mutations" v-for="mutation in mutations"
:key="mutation.id" :key="mutation.id"
class="c-mutation-list__item" class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
> >
<div class="c-mutation-list__item-header"> <div class="flex items-start justify-between">
<div> <div>
<span class="c-mutation-list__item-author"> <span class="font-semibold">
{{ {{
isOwnerUser(mutation.user_id) isOwnerUser(mutation.user_id)
? 'Added by' ? 'Added by'
@ -84,10 +67,13 @@ function getAmountClass(amount: number): string {
}} }}
{{ mutation.user.name }} {{ mutation.user.name }}
</span> </span>
<div class="c-mutation-list__item-meta"> <div class="mt-1 flex items-center gap-2">
<span <span
:class="getAmountClass(mutation.amount)" :class="{
class="c-mutation-list__item-amount" 'text-green-500': mutation.amount > 0,
'text-red-500': mutation.amount < 0,
}"
class="text-sm font-bold"
> >
{{ mutation.amount > 0 ? '+' : '' {{ mutation.amount > 0 ? '+' : ''
}}{{ mutation.amount }} }}{{ mutation.amount }}
@ -96,57 +82,59 @@ function getAmountClass(amount: number): string {
<span <span
v-if="!isOwnerUser(mutation.user_id)" v-if="!isOwnerUser(mutation.user_id)"
:class="{ :class="{
'c-mutation-list__item-status--pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400':
mutation.status === 'pending', mutation.status === 'pending',
'c-mutation-list__item-status--approved': 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400':
mutation.status === 'approved', mutation.status === 'approved',
'c-mutation-list__item-status--rejected': 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400':
mutation.status === 'rejected', mutation.status === 'rejected',
}" }"
class="c-mutation-list__item-status" class="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
> >
{{ mutation.status }} {{ mutation.status }}
</span> </span>
</div> </div>
</div> </div>
<div class="c-mutation-list__item-time"> <div class="text-xs text-gray-500">
{{ new Date(mutation.created_at).toLocaleString() }} {{ new Date(mutation.created_at).toLocaleString() }}
</div> </div>
</div> </div>
<p class="c-mutation-list__item-desc"> <p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
{{ mutation.description }} {{ mutation.description }}
</p> </p>
<!-- Attached Mutation Proof Media --> <!-- Attached Mutation Proof Media -->
<div <div
v-if="mutation.media && mutation.media.length > 0" v-if="mutation.media && mutation.media.length > 0"
class="c-mutation-list__media-list" class="mt-3 flex flex-wrap gap-2"
> >
<div <div
v-for="item in mutation.media" v-for="item in mutation.media"
:key="item.id" :key="item.id"
class="c-mutation-list__media-item" class="max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="c-mutation-list__media-img" class="h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
@click=" @click="
emit('open-lightbox', item.url, item.mime_type) emit('open-lightbox', item.url, item.mime_type)
" "
/> />
<div <div
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
class="c-mutation-list__media-video-wrapper" class="relative cursor-pointer transition-opacity hover:opacity-90"
@click=" @click="
emit('open-lightbox', item.url, item.mime_type) emit('open-lightbox', item.url, item.mime_type)
" "
> >
<video <video
:src="item.url" :src="item.url"
class="c-mutation-list__media-video" class="h-auto max-h-[150px] w-full"
></video> ></video>
<div class="c-mutation-list__media-video-overlay"> <div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div> </div>
</div> </div>
@ -156,17 +144,17 @@ function getAmountClass(amount: number): string {
<!-- Owner Approve/Reject Actions --> <!-- Owner Approve/Reject Actions -->
<div <div
v-if="isOwner && mutation.status === 'pending'" v-if="isOwner && mutation.status === 'pending'"
class="c-mutation-list__actions" class="mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700"
> >
<button <button
@click="updateStatus(mutation.id, 'approved')" @click="updateStatus(mutation.id, 'approved')"
class="c-mutation-list__approve-btn" 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 Approve
</button> </button>
<button <button
@click="updateStatus(mutation.id, 'rejected')" @click="updateStatus(mutation.id, 'rejected')"
class="c-mutation-list__reject-btn" 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 Reject
</button> </button>
@ -175,120 +163,8 @@ function getAmountClass(amount: number): string {
<Chat :chat="mutation.chat" /> <Chat :chat="mutation.chat" />
</li> </li>
</ul> </ul>
<div v-if="mutations.length === 0" class="c-mutation-list__empty"> <div v-if="mutations.length === 0" class="mt-4 text-gray-500">
No mutations found for this ledger. No mutations found for this ledger.
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-mutation-list {
@apply mt-8;
}
.c-mutation-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-mutation-list__list {
@apply mt-4 space-y-4;
}
.c-mutation-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-mutation-list__item-header {
@apply flex items-start justify-between;
}
.c-mutation-list__item-author {
@apply font-semibold;
}
.c-mutation-list__item-meta {
@apply mt-1 flex items-center gap-2;
}
.c-mutation-list__item-amount {
@apply text-sm font-bold;
}
.c-mutation-list__item-amount--positive {
@apply text-green-500;
}
.c-mutation-list__item-amount--negative {
@apply text-red-500;
}
.c-mutation-list__item-amount--neutral {
@apply text-gray-500 dark:text-gray-400;
}
.c-mutation-list__item-status {
@apply rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase;
}
.c-mutation-list__item-status--pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
}
.c-mutation-list__item-status--approved {
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
}
.c-mutation-list__item-status--rejected {
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
}
.c-mutation-list__item-time {
@apply text-xs text-gray-500;
}
.c-mutation-list__item-desc {
@apply mt-3 text-sm text-gray-600 dark:text-gray-400;
}
.c-mutation-list__media-list {
@apply mt-3 flex flex-wrap gap-2;
}
.c-mutation-list__media-item {
@apply max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-mutation-list__media-img {
@apply h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-mutation-list__media-video {
@apply h-auto max-h-[150px] w-full;
}
.c-mutation-list__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.c-mutation-list__actions {
@apply mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700;
}
.c-mutation-list__approve-btn {
@apply inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500;
}
.c-mutation-list__reject-btn {
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
}
.c-mutation-list__empty {
@apply mt-4 text-gray-500;
}
</style>

View File

@ -8,38 +8,18 @@ defineProps<{
</script> </script>
<template> <template>
<div class="c-participants-list"> <div class="mt-8">
<h4 class="c-participants-list__title"> <h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
Participants Participants
</h4> </h4>
<ul class="c-participants-list__grid"> <ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<li <li
v-for="participant in participants" v-for="participant in participants"
:key="participant.id" :key="participant.id"
class="c-participants-list__item" class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
> >
{{ participant.name }} {{ participant.name }}
</li> </li>
</ul> </ul>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-participants-list {
@apply mt-8;
}
.c-participants-list__title {
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
}
.c-participants-list__grid {
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
}
.c-participants-list__item {
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { dashboard } from '@/routes'; import { dashboard } from '@/routes';
defineOptions({ defineOptions({
@ -12,256 +13,35 @@ defineOptions({
], ],
}, },
}); });
defineProps<{
unreadEntities: Array<{
id: number;
name: string;
type: 'Dynamic' | 'Ledger';
url: string;
unread_count: number;
context_activities: Array<{
id: string;
type: string;
description: string;
user: { name: string };
created_at: string;
}>;
new_activities: Array<{
id: string;
type: string;
description: string;
user: { name: string };
created_at: string;
}>;
}>;
}>();
function formatTime(isoString: string): string {
return new Date(isoString).toLocaleString();
}
</script> </script>
<template> <template>
<Head title="Dashboard" /> <Head title="Dashboard" />
<div class="c-dashboard"> <div
<div class="c-dashboard__container"> class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
<h2 class="c-dashboard__title">Recent Activity</h2> >
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div v-if="unreadEntities.length > 0" class="c-dashboard__grid"> <div
<div class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
v-for="entity in unreadEntities" >
:key="`${entity.type}_${entity.id}`" <PlaceholderPattern />
class="c-dashboard__card"
>
<div class="c-dashboard__card-header">
<div class="c-dashboard__entity-meta">
<span
:class="[
'c-dashboard__badge-type',
entity.type === 'Dynamic'
? 'c-dashboard__badge-type--dynamic'
: 'c-dashboard__badge-type--ledger'
]"
>
{{ entity.type }}
</span>
<span class="c-dashboard__unread-count">
{{ entity.unread_count }} New
</span>
</div>
<Link :href="entity.url" class="c-dashboard__entity-link">
<h3 class="c-dashboard__entity-title">
{{ entity.name }}
</h3>
</Link>
</div>
<div class="c-dashboard__activity-list">
<!-- Context / Read Activities -->
<div
v-for="activity in entity.context_activities"
:key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read"
>
<div class="c-dashboard__activity-meta">
<span class="c-dashboard__activity-user">
{{ activity.user.name }}
</span>
<span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }}
</span>
</div>
<p class="c-dashboard__activity-desc">
{{ activity.description }}
</p>
</div>
<!-- Unread Separator Line -->
<div
v-if="entity.context_activities.length > 0"
class="c-dashboard__divider"
>
<span class="c-dashboard__divider-text">New Activity Below</span>
</div>
<!-- New / Unread Activities -->
<div
v-for="activity in entity.new_activities"
:key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread"
>
<div class="c-dashboard__activity-meta">
<span class="c-dashboard__activity-user">
{{ activity.user.name }}
</span>
<span class="c-dashboard__activity-time">
{{ formatTime(activity.created_at) }}
</span>
<span class="c-dashboard__new-badge">NEW</span>
</div>
<p class="c-dashboard__activity-desc">
{{ activity.description }}
</p>
</div>
</div>
</div>
</div> </div>
<div
<!-- Empty Caught-Up State --> class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
<div v-else class="c-dashboard__empty-state"> >
<div class="c-dashboard__empty-icon"> <PlaceholderPattern />
🔒
</div>
<p class="c-dashboard__empty-text">
All chambers are currently quiet.
</p>
<p class="c-dashboard__empty-subtext">
Your records are completely up to date. Masterfully done.
</p>
</div> </div>
<div
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"
>
<PlaceholderPattern />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../css/app.css";
.c-dashboard {
@apply py-8 px-4 sm:px-6 lg:px-8;
}
.c-dashboard__container {
@apply mx-auto max-w-7xl;
}
.c-dashboard__title {
@apply text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100 mb-6;
}
.c-dashboard__grid {
@apply grid grid-cols-1 gap-6 md:grid-cols-2;
}
.c-dashboard__card {
@apply flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900;
}
.c-dashboard__card-header {
@apply p-6 border-b border-neutral-100 dark:border-neutral-800/30;
}
.c-dashboard__entity-meta {
@apply flex items-center gap-2 mb-2;
}
.c-dashboard__badge-type {
@apply text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded;
}
.c-dashboard__badge-type--dynamic {
@apply bg-purple-100 text-purple-800 dark:bg-purple-950/20 dark:text-purple-400;
}
.c-dashboard__badge-type--ledger {
@apply bg-indigo-100 text-indigo-800 dark:bg-indigo-950/20 dark:text-indigo-400;
}
.c-dashboard__unread-count {
@apply 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;
}
.c-dashboard__entity-link {
@apply hover:underline focus:outline-none;
}
.c-dashboard__entity-title {
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
}
.c-dashboard__activity-list {
@apply flex-1 p-6 space-y-4;
}
.c-dashboard__activity-item {
@apply p-4 rounded-lg border;
}
.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;
}
.c-dashboard__activity-item--unread {
@apply bg-red-50/10 border-red-200/50 dark:bg-red-950/5 dark:border-red-900/30;
}
.c-dashboard__activity-meta {
@apply flex items-center gap-2 mb-1.5 text-xs;
}
.c-dashboard__activity-user {
@apply font-semibold text-neutral-800 dark:text-neutral-200;
}
.c-dashboard__activity-time {
@apply text-neutral-400 dark:text-neutral-500;
}
.c-dashboard__new-badge {
@apply ml-auto 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;
}
.c-dashboard__activity-desc {
@apply text-sm text-neutral-600 dark:text-neutral-400;
}
.c-dashboard__divider {
@apply relative flex items-center justify-center my-4;
}
.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;
}
.c-dashboard__divider::before {
content: '';
@apply absolute inset-x-0 h-px bg-neutral-200 dark:bg-neutral-800;
}
.c-dashboard__empty-state {
@apply flex 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];
}
.c-dashboard__empty-icon {
@apply text-4xl mb-4;
}
.c-dashboard__empty-text {
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
}
.c-dashboard__empty-subtext {
@apply mt-1 text-sm text-neutral-500 dark:text-neutral-500 max-w-md;
}
</style>

View File

@ -26,58 +26,60 @@ function submit() {
<template> <template>
<Head title="Create Dynamic" /> <Head title="Create Dynamic" />
<div class="c-dynamics-create"> <div class="py-12">
<div class="c-dynamics-create__container"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="c-dynamics-create__card"> <div
<div class="c-dynamics-create__body"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<h3 class="c-dynamics-create__title">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="c-dynamics-create__form"> <form @submit.prevent="submit" class="mt-6 space-y-6">
<div class="c-dynamics-create__field"> <div>
<label <label
for="name" for="name"
class="c-dynamics-create__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Name</label >Name</label
> >
<input <input
v-model="form.name" v-model="form.name"
id="name" id="name"
type="text" type="text"
class="c-dynamics-create__input" 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 <div
v-if="form.errors.name" v-if="form.errors.name"
class="c-dynamics-create__error" class="text-sm text-red-600"
> >
{{ form.errors.name }} {{ form.errors.name }}
</div> </div>
</div> </div>
<div class="c-dynamics-create__field"> <div>
<label <label
for="rules" for="rules"
class="c-dynamics-create__label" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Rules</label >Rules</label
> >
<textarea <textarea
v-model="form.rules" v-model="form.rules"
id="rules" id="rules"
rows="4" rows="4"
class="c-dynamics-create__textarea" 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> ></textarea>
<div <div
v-if="form.errors.rules" v-if="form.errors.rules"
class="c-dynamics-create__error" class="text-sm text-red-600"
> >
{{ form.errors.rules }} {{ form.errors.rules }}
</div> </div>
</div> </div>
<div class="c-dynamics-create__actions"> <div class="flex items-center gap-4">
<button <button
type="submit" type="submit"
:disabled="form.processing" :disabled="form.processing"
class="c-dynamics-create__submit-btn" 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 Create
</button> </button>
@ -88,59 +90,3 @@ function submit() {
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-dynamics-create {
@apply py-12;
}
.c-dynamics-create__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamics-create__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-dynamics-create__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-dynamics-create__title {
@apply text-lg font-medium;
}
.c-dynamics-create__form {
@apply mt-6 space-y-6;
}
.c-dynamics-create__field {
@apply block;
}
.c-dynamics-create__label {
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
}
.c-dynamics-create__input {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-dynamics-create__textarea {
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
}
.c-dynamics-create__error {
@apply text-sm text-red-600;
}
.c-dynamics-create__actions {
@apply flex items-center gap-4;
}
.c-dynamics-create__submit-btn {
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
}
</style>

View File

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

View File

@ -37,12 +37,14 @@ const breadcrumbs = [
<template> <template>
<Head :title="dynamic.name" /> <Head :title="dynamic.name" />
<div class="c-dynamic-show"> <div class="py-12">
<div class="c-dynamic-show__container"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="c-dynamic-show__card"> <div
<div class="c-dynamic-show__body"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3> >
<p class="c-dynamic-show__rules"> <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 }} {{ dynamic.rules }}
</p> </p>
</div> </div>
@ -62,31 +64,3 @@ const breadcrumbs = [
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-dynamic-show {
@apply py-12;
}
.c-dynamic-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-dynamic-show__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-dynamic-show__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-dynamic-show__title {
@apply text-lg font-medium;
}
.c-dynamic-show__rules {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
</style>

View File

@ -22,7 +22,6 @@ const props = defineProps<{
name: string; name: string;
score: number; score: number;
rules: string; rules: string;
alignment: string;
media?: Array<{ id: number; url: string; mime_type: string }>; media?: Array<{ id: number; url: string; mime_type: string }>;
mutations: Array<{ mutations: Array<{
id: number; id: number;
@ -148,82 +147,66 @@ function isOwnerUser(userId: number): boolean {
<Head :title="ledger.name" /> <Head :title="ledger.name" />
<!-- Floating Toast Notifications --> <!-- Floating Toast Notifications -->
<div class="c-ledger-show__toast-container"> <div
class="pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3"
>
<div <div
v-for="toast in toasts" v-for="toast in toasts"
:key="toast.id" :key="toast.id"
class="c-ledger-show__toast-item" 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"
> >
<span>{{ toast.message }}</span> <span>{{ toast.message }}</span>
<button <button
@click="toasts = toasts.filter((t) => t.id !== toast.id)" @click="toasts = toasts.filter((t) => t.id !== toast.id)"
class="c-ledger-show__toast-close-btn" class="cursor-pointer text-neutral-400 hover:text-white"
> >
</button> </button>
</div> </div>
</div> </div>
<div class="c-ledger-show"> <div class="py-12">
<div class="c-ledger-show__container"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="c-ledger-show__card"> <div
<div class="c-ledger-show__body"> class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3> >
<p class="c-ledger-show__score"> <div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium">{{ ledger.name }}</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Score: {{ ledger.score }} Score: {{ ledger.score }}
</p> </p>
<p class="c-ledger-show__rules"> <p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ ledger.rules }} {{ ledger.rules }}
</p> </p>
<!-- Ledger Alignment Badge / Subtitle -->
<div class="c-ledger-show__alignment-wrapper">
<span
v-if="ledger.alignment === 'positive'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--positive"
>
Positive Alignment A higher score is better.
</span>
<span
v-else-if="ledger.alignment === 'negative'"
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative"
>
Negative Alignment A lower score is better (demerits / infractions).
</span>
<span
v-else
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--neutral"
>
Neutral Alignment Scorekeeping neutral.
</span>
</div>
<!-- Ledger Descriptive Media --> <!-- Ledger Descriptive Media -->
<div <div
v-if="ledger.media && ledger.media.length > 0" v-if="ledger.media && ledger.media.length > 0"
class="c-ledger-show__media-list" class="mt-4 flex flex-wrap gap-3"
> >
<div <div
v-for="item in ledger.media" v-for="item in ledger.media"
:key="item.id" :key="item.id"
class="c-ledger-show__media-item" class="max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
> >
<img <img
v-if="item.mime_type.startsWith('image/')" v-if="item.mime_type.startsWith('image/')"
:src="item.url" :src="item.url"
class="c-ledger-show__media-img" class="h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)" @click="openLightbox(item.url, item.mime_type)"
/> />
<div <div
v-else-if="item.mime_type.startsWith('video/')" v-else-if="item.mime_type.startsWith('video/')"
class="c-ledger-show__media-video-wrapper" class="relative cursor-pointer transition-opacity hover:opacity-90"
@click="openLightbox(item.url, item.mime_type)" @click="openLightbox(item.url, item.mime_type)"
> >
<video <video
:src="item.url" :src="item.url"
class="c-ledger-show__media-video" class="h-auto max-h-[200px] w-full"
></video> ></video>
<div class="c-ledger-show__media-video-overlay"> <div
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
>
</div> </div>
</div> </div>
@ -242,7 +225,6 @@ function isOwnerUser(userId: number): boolean {
:mutations="ledger.mutations" :mutations="ledger.mutations"
:participants="dynamic.participants" :participants="dynamic.participants"
:is-owner="isOwner" :is-owner="isOwner"
:ledger-alignment="ledger.alignment"
@open-lightbox="openLightbox" @open-lightbox="openLightbox"
/> />
</div> </div>
@ -251,136 +233,28 @@ function isOwnerUser(userId: number): boolean {
<!-- Lightbox Modal --> <!-- Lightbox Modal -->
<div <div
v-if="activeLightboxUrl" v-if="activeLightboxUrl"
class="c-ledger-show__lightbox" class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4"
@click="closeLightbox" @click="closeLightbox"
> >
<button <button
@click="closeLightbox" @click="closeLightbox"
class="c-ledger-show__lightbox-close" class="absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500"
> >
</button> </button>
<div class="c-ledger-show__lightbox-content" @click.stop> <div class="max-h-full max-w-full" @click.stop>
<img <img
v-if="activeLightboxType === 'image'" v-if="activeLightboxType === 'image'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
class="c-ledger-show__lightbox-img" class="max-h-[90vh] max-w-full rounded object-contain shadow-lg"
/> />
<video <video
v-else-if="activeLightboxType === 'video'" v-else-if="activeLightboxType === 'video'"
:src="activeLightboxUrl" :src="activeLightboxUrl"
controls controls
autoplay autoplay
class="c-ledger-show__lightbox-video" class="max-h-[90vh] max-w-full rounded shadow-lg"
></video> ></video>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
@reference "../../../css/app.css";
.c-ledger-show__toast-container {
@apply pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3;
}
.c-ledger-show__toast-item {
@apply pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl;
}
.c-ledger-show__toast-close-btn {
@apply cursor-pointer text-neutral-400 hover:text-white;
}
.c-ledger-show {
@apply py-12;
}
.c-ledger-show__container {
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
}
.c-ledger-show__card {
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
}
.c-ledger-show__body {
@apply p-6 text-gray-900 dark:text-gray-100;
}
.c-ledger-show__title {
@apply text-lg font-medium;
}
.c-ledger-show__score {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-ledger-show__rules {
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
}
.c-ledger-show__alignment-wrapper {
@apply mt-3 flex items-center;
}
.c-ledger-show__alignment-badge {
@apply text-xs font-semibold uppercase tracking-wide px-2.5 py-1 rounded;
}
.c-ledger-show__alignment-badge--positive {
@apply text-green-600 bg-green-50/50 dark:text-green-400 dark:bg-green-950/20;
}
.c-ledger-show__alignment-badge--negative {
@apply text-red-600 bg-red-50/50 dark:text-red-400 dark:bg-red-950/20;
}
.c-ledger-show__alignment-badge--neutral {
@apply text-gray-500 bg-gray-50/50 dark:text-gray-400 dark:bg-neutral-800/20;
}
.c-ledger-show__media-list {
@apply mt-4 flex flex-wrap gap-3;
}
.c-ledger-show__media-item {
@apply max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
}
.c-ledger-show__media-img {
@apply h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
}
.c-ledger-show__media-video-wrapper {
@apply relative cursor-pointer transition-opacity hover:opacity-90;
}
.c-ledger-show__media-video {
@apply h-auto max-h-[200px] w-full;
}
.c-ledger-show__media-video-overlay {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
}
.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>

View File

@ -1,6 +1,5 @@
<?php <?php
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DynamicController; use App\Http\Controllers\DynamicController;
use App\Http\Controllers\LedgerController; use App\Http\Controllers\LedgerController;
use App\Http\Controllers\MessageController; use App\Http\Controllers\MessageController;
@ -10,7 +9,7 @@ use Illuminate\Support\Facades\Route;
Route::inertia('/', 'Welcome')->name('home'); Route::inertia('/', 'Welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::resource('dynamics', DynamicController::class); Route::resource('dynamics', DynamicController::class);
Route::resource('dynamics.ledgers', LedgerController::class)->scoped(); Route::resource('dynamics.ledgers', LedgerController::class)->scoped();

View File

@ -1,12 +1,6 @@
<?php <?php
use App\Models\User; use App\Models\User;
use App\Models\Dynamic;
use App\Models\Ledger;
use App\Models\Mutation;
use App\Models\Message;
use App\Services\ActivityService;
use Illuminate\Support\Carbon;
test('guests are redirected to the login page', function () { test('guests are redirected to the login page', function () {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
@ -19,106 +13,4 @@ test('authenticated users can visit the dashboard', function () {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
$response->assertOk(); $response->assertOk();
$response->assertInertia(fn ($page) => $page->component('Dashboard')->has('unreadEntities'));
});
test('visiting dynamic updates the read cursor', function () {
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
$this->actingAs($user);
$service = app(ActivityService::class);
$initialCursor = $service->getCursorReadAt($user, $dynamic);
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Dynamic Show
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
// Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $dynamic);
expect($updatedCursor->gt($initialCursor))->toBeTrue();
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
});
test('visiting ledger updates the read cursor', function () {
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create();
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
$this->actingAs($user);
$service = app(ActivityService::class);
$initialCursor = $service->getCursorReadAt($user, $ledger);
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
// Visit Ledger Show
$this->get(route('dynamics.ledgers.show', [$dynamic->id, $ledger->id]))->assertOk();
// Re-check cursor is updated to near now
$updatedCursor = $service->getCursorReadAt($user, $ledger);
expect($updatedCursor->gt($initialCursor))->toBeTrue();
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
});
test('dashboard groups and filters unread entities correctly based on cursor', function () {
$user = User::factory()->create();
$dynamic = Dynamic::factory()->create(['name' => 'Testing Dynamic']);
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
// Create custom Chat so booted has chat available
$dynamic->chat()->create([]);
$this->actingAs($user);
// Create past message (already read)
$pastMsg = Message::create([
'chat_id' => $dynamic->chat->id,
'user_id' => $user->id,
'content' => 'Old message context',
]);
// Artificially advance cursor to after past message
Carbon::setTestNow(Carbon::now()->addMinutes(5));
$service = app(ActivityService::class);
$service->updateCursor($user, $dynamic);
// Create new unread message
Carbon::setTestNow(Carbon::now()->addMinutes(5));
$unreadMsg = Message::create([
'chat_id' => $dynamic->chat->id,
'user_id' => $user->id,
'content' => 'New unread message alert',
]);
// Retrieve unread groupings
$response = $this->get(route('dashboard'));
$response->assertOk();
// Verify unread grouping structure
$response->assertInertia(fn ($page) => $page
->component('Dashboard')
->where('unreadEntities.0.name', 'Testing Dynamic')
->where('unreadEntities.0.unread_count', 1)
->has('unreadEntities.0.context_activities', 1) // Should have old message as context
->where('unreadEntities.0.context_activities.0.description', 'Old message context')
->has('unreadEntities.0.new_activities', 1) // Should have unread message
->where('unreadEntities.0.new_activities.0.description', 'New unread message alert')
);
// Now visit the Dynamic, which clears the unread count
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
// Dashboard should now show 0 unread groups (caught up)
$response2 = $this->get(route('dashboard'));
$response2->assertOk();
$response2->assertInertia(fn ($page) => $page
->component('Dashboard')
->has('unreadEntities', 0)
);
Carbon::setTestNow(); // Reset test time
}); });