Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a92334125 | ||
|
|
93e8f035e6 | ||
|
|
3c414e1ef1 | ||
|
|
b9d2988e8c | ||
|
|
69762667bc | ||
|
|
f493be6512 | ||
|
|
eeb3bf85a9 | ||
|
|
ef00a73daa | ||
|
|
ae6a4304f9 | ||
|
|
d01091d5a6 | ||
|
|
8f9c4c9d1f | ||
|
|
5b1a634d80 | ||
|
|
379543a351 | ||
|
|
06cd53fe91 | ||
|
|
5404b1a535 | ||
|
|
cb01acd6e0 | ||
|
|
4e9e6dce2a | ||
|
|
d44bcf6fda | ||
|
|
9a9a901d46 | ||
|
|
a687d7ac4d | ||
|
|
f12639fb59 | ||
|
|
e32fbc10e0 | ||
|
|
94aae59f8e | ||
|
|
3dd7c48b08 | ||
|
|
bf7da48a58 |
@@ -58,6 +58,12 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
- Stick to existing directory structure; don't create new base folders without approval.
|
- Stick to existing directory structure; don't create new base folders without approval.
|
||||||
- Do not change the application's dependencies without approval.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
|
## Activity Service
|
||||||
|
|
||||||
|
- The `app/Services/ActivityService.php` class is used to create system messages and activities.
|
||||||
|
- To create a system message, use the `createMessage` method. The `$user` parameter should be `null` to indicate a system message.
|
||||||
|
- The `createMutation` method can be used to create a mutation and its associated system message.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
|
|
||||||
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
|
||||||
@@ -131,6 +137,7 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac
|
|||||||
|
|
||||||
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
|
||||||
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
|
||||||
|
- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run.
|
||||||
|
|
||||||
=== inertia-laravel/core rules ===
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
|
|||||||
+33
-5
@@ -19,18 +19,46 @@ This document outlines the decisions made during the development of the Ledgerrz
|
|||||||
|
|
||||||
## Architectural & Integration Decisions
|
## Architectural & Integration Decisions
|
||||||
|
|
||||||
### 1. Style Architecture Shift to BEM
|
### 1. Style Architecture Shift to BEM & Modern CSS Nesting
|
||||||
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks. To keep the look and feel 100% intact, we used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
|
To improve front-end maintainability, we transitioned the core reusable layout and page components away from raw utility-class markup. Styles are now strictly encapsulated within Vue Single File Component (SFC) `<style scoped>` blocks (or dedicated BEM component files under `/resources/css/components/`).
|
||||||
*Note:* Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
|
* **CSS Nesting Standard (Critical Learning):** Standard CSS/PostCSS nesting (unlike SASS) **does not** support class suffix concatenation (e.g. `.block { &__element { ... } }` is invalid). All nested selectors must declare the full class name explicitly (e.g. `.block { .block__element { ... } }`), compiling to standard descendant selectors (`.block .block__element`). Suffix nesting was refactored across all 12 component stylesheets to ensure native CSS browser and LightningCSS compatibility.
|
||||||
|
* **Encapsulation:** Used Tailwind v4 `@apply` directives inside these blocks, referencing our central stylesheet (`@reference "../../css/app.css"`) to pull in custom themes and variables cleanly.
|
||||||
|
* **Third-Party Safety:** Third-party shadcn-vue library primitives (located in `components/ui/`) were kept intact to preserve vendor update integrity.
|
||||||
|
|
||||||
### 2. Robust Real-time Echo Fallback & Deduplication
|
### 2. BelongsToMany Pivot Loading caveat
|
||||||
|
When accessing pivot attributes on relationship models on the front-end (e.g. `participant.pivot.role`), the relationship definition in the Eloquent model **must** explicitly call `->withPivot('role')`. Omitting this will cause the pivot object to fail loading role attributes silently in Javascript, breaking role-based template gates. We added this to `App\Models\Dynamic::participants()`.
|
||||||
|
|
||||||
|
### 3. Robust Real-time Echo Fallback & Deduplication
|
||||||
To address potential initialization and bundling errors under local-development:
|
To address potential initialization and bundling errors under local-development:
|
||||||
* **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles.
|
* **Module Deduplication:** Configured `resolve.dedupe: ['@laravel/echo-vue']` in `vite.config.ts` to ensure exactly one instance of the Echo plugin and configuration state is shared across main and lazy-loaded bundles.
|
||||||
* **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally).
|
* **Defensive Initialization:** Added checks using `echoIsConfigured()` and configured fallback connection parameters (e.g., `'mock-key'`) in both `app.ts` and `Chat.vue`'s setup functions. This ensures that missing environment variables (such as `VITE_REVERB_APP_KEY`) do not trigger fatal Pusher initialization crashes that block component rendering, allowing the websocket to fail silently (which is correct when Reverb is not running locally).
|
||||||
|
|
||||||
### 3. Controller Authorization Fix
|
### 4. Controller Authorization Fix
|
||||||
We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`.
|
We imported the `Illuminate\Foundation\Auth\Access\AuthorizesRequests` trait directly into `LedgerController.php`. This fixes the 500 Internal Server Error when loading the ledger page, which was caused by `LedgerController` calling `$this->authorize('view', ...)` without inheriting the required method from the Laravel 11 base `Controller`.
|
||||||
|
|
||||||
|
### 5. Polymorphic Multiple-Media Attachment Support
|
||||||
|
We added a polymorphic attachments system allowing any database model to attach multiple photos and videos cleanly:
|
||||||
|
* Created `Media` polymorphic model and a migration mapping `mediable_id` and `mediable_type`.
|
||||||
|
* Attached and verified uploads across `Message` (inline chat images/videos), `Ledger` (cover documents), and `Mutation` (chore submission receipts and proof).
|
||||||
|
* Built a highly reusable `.c-lightbox` CSS component for modal previews of both image and video uploads, avoiding duplication in individual modules.
|
||||||
|
|
||||||
|
### 6. Cryptographically Secure Dynamic Invitation System
|
||||||
|
We implemented a secure Dynamic Invitation flow to allow owners to invite new members to a Dynamic under a specific role:
|
||||||
|
* **Signed temporary URLs:** Invitation links dispatched in mailable emails (`DynamicInvitationMail.php`) use Laravel's temporary signed URLs, expiring in 7 days, to prevent link tampering.
|
||||||
|
* **Intended-Email Check Gate:** To prevent link hijacking, the accept gate strictly checks that the authenticated user's email matches the exact email specified on the invitation:
|
||||||
|
```php
|
||||||
|
if ($request->user()->email !== $invitation->email) {
|
||||||
|
abort(403, 'This invitation was sent to a different email address.');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Access Control:** Access to invitations and creation is fully protected under Owner-only authorization checks.
|
||||||
|
|
||||||
|
### 7. Centralized Activity Service for System Messages
|
||||||
|
We created `app/Services/ActivityService.php` to centralize the creation of system messages and activities.
|
||||||
|
* **System Messages as `null` user_id**: System messages are stored as `Message` records with a `null` `user_id`, cleanly distinguishing them from user-generated content.
|
||||||
|
* **Polymorphic Subject Linking**: System messages are linked to relevant entities (e.g., a `User` who joined a dynamic, a `Ledger` that was created) via a polymorphic `subject` relationship on the `messages` table. This allows system messages on the dashboard to link directly to the relevant entity.
|
||||||
|
* **Seeder Refactoring**: The `DatabaseSeeder` was refactored to use the `ActivityService` to generate all system messages, ensuring consistency.
|
||||||
|
|
||||||
## Initial Database Schema
|
## Initial Database Schema
|
||||||
|
|
||||||
I will start with a basic schema and evolve it as I build features.
|
I will start with a basic schema and evolve it as I build features.
|
||||||
|
|||||||
@@ -9,3 +9,35 @@ Dynamics have a rules-segment too. Not all people within a dynamic can edit thes
|
|||||||
The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future.
|
The interface should be in Vue, nicely structured into components and pages. Esthetic is BDSM centered, but clean. Start with dark mode, light mode (camouflaged as a more innocent, corporate esthetical theme) might be a good feature in the future.
|
||||||
|
|
||||||
Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features.
|
Make use of inertia, laravel reverb for notifications, pest for tests. Make git commits for features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Developments & Achievements
|
||||||
|
|
||||||
|
During this session, we successfully built out and verified several core architectural features of the Ledgerrz application:
|
||||||
|
|
||||||
|
1. **BEM Styling Refactoring (BDSM Theme first)**:
|
||||||
|
* Replaced inline Tailwind classes across all core Vue pages and layout components (`Dynamics/Index.vue`, `Dynamics/Create.vue`, `Dynamics/Show.vue`, `Ledgers/Show.vue`, `ParticipantsList.vue`, `LedgerList.vue`, `CreateLedgerForm.vue`, `AddMutationForm.vue`, and `MutationList.vue`) with structured BEM scoped CSS blocks.
|
||||||
|
* Utilized Tailwind v4 `@apply` and `@reference` inside `<style scoped>` blocks to prevent code duplication, enforce a dark-first BDSM aesthetic, and maintain clear separation of concerns.
|
||||||
|
|
||||||
|
2. **Ledger Alignment & Scoring Logic**:
|
||||||
|
* Added an `alignment` column (`positive`, `neutral`, `negative`) to Ledgers, affecting the color coding of mutations:
|
||||||
|
* *Positive Alignment*: higher score is better; additions are green (+), demerits/deductions are red (-).
|
||||||
|
* *Negative Alignment*: lower score is better (demerits); deductions are green (-) as they reduce demerits, and additions/infractions are red (+) as they increase demerits.
|
||||||
|
* *Neutral Alignment*: standard scoring, styled in gray/neutral colors.
|
||||||
|
* Added an "Alignment" dropdown selector in the ledger creation form.
|
||||||
|
|
||||||
|
3. **Demerit Authorship Correction**:
|
||||||
|
* Corrected database seeders so demerits/penalty mutations are authored by the Dominant/Owner user (`alice` or `testUser`) rather than the submissive (`bob`), aligning with realistic power exchange dynamics.
|
||||||
|
|
||||||
|
4. **Read Cursor & Dashboard Activities Highlights**:
|
||||||
|
* Designed and built a polymorphic `read_cursors` table to track a user's last-viewed timestamp on individual Dynamics and Ledgers.
|
||||||
|
* Developed `ActivityService` to chronological sort and partition dynamic activity (messages, ledger creation) and ledger activity (mutations, approval updates, and comments) into unread vs. read categories.
|
||||||
|
* Highlighted unread entities grouped together on the user's dashboard, displaying the **last two already-read items as muted context** followed by highlighted unread activities with a prominent `NEW` badge.
|
||||||
|
* Visiting a Dynamic/Ledger automatically updates its read cursor, removing it from subsequent dashboard loads.
|
||||||
|
* Dynamically shared the unread entity count as a global Inertia prop, rendering a vibrant red dot notification badge over a bell icon in the top-right of the header.
|
||||||
|
|
||||||
|
5. **Broadcasts, Environment & Verification**:
|
||||||
|
* Configured real-time notifications utilizing Laravel Reverb.
|
||||||
|
* Documented CLI environment test pollution learnings inside `AGENTS.md` to prevent future CSRF `419` errors.
|
||||||
|
* Ensured full production assets compilation (`npm run build`) and achieved **45/45 passing Pest PHP tests with 206 assertions**.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\ActivityService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, ActivityService $activityService)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$unreadEntities = $activityService->getUnreadEntitiesGrouped($user);
|
||||||
|
|
||||||
|
return Inertia::render('Dashboard', [
|
||||||
|
'unreadEntities' => $unreadEntities,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\StoreDynamicRequest;
|
use App\Http\Requests\StoreDynamicRequest;
|
||||||
|
use App\Http\Requests\UpdateDynamicRequest;
|
||||||
use App\Models\Dynamic;
|
use App\Models\Dynamic;
|
||||||
|
use App\Services\ActivityService;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -44,10 +46,12 @@ class DynamicController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Dynamic $dynamic)
|
public function show(Request $request, Dynamic $dynamic, ActivityService $activityService)
|
||||||
{
|
{
|
||||||
$this->authorize('view', $dynamic);
|
$this->authorize('view', $dynamic);
|
||||||
|
|
||||||
|
$activityService->updateCursor($request->user(), $dynamic);
|
||||||
|
|
||||||
$dynamic->load([
|
$dynamic->load([
|
||||||
'ledgers.media',
|
'ledgers.media',
|
||||||
'participants',
|
'participants',
|
||||||
@@ -55,8 +59,14 @@ class DynamicController extends Controller
|
|||||||
'chat.messages.media'
|
'chat.messages.media'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$isOwner = $dynamic->participants()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->where('role', 'owner')
|
||||||
|
->exists();
|
||||||
|
|
||||||
return Inertia::render('Dynamics/Show', [
|
return Inertia::render('Dynamics/Show', [
|
||||||
'dynamic' => $dynamic,
|
'dynamic' => $dynamic,
|
||||||
|
'isOwner' => $isOwner,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +75,21 @@ class DynamicController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function edit(Dynamic $dynamic)
|
public function edit(Dynamic $dynamic)
|
||||||
{
|
{
|
||||||
//
|
$this->authorize('update', $dynamic);
|
||||||
|
|
||||||
|
return Inertia::render('Dynamics/Settings', [
|
||||||
|
'dynamic' => $dynamic,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, Dynamic $dynamic)
|
public function update(UpdateDynamicRequest $request, Dynamic $dynamic)
|
||||||
{
|
{
|
||||||
//
|
$dynamic->update($request->validated());
|
||||||
|
|
||||||
|
return redirect()->route('dynamics.show', $dynamic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Mail\DynamicInvitationMail;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\DynamicInvitation;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class DynamicInvitationController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
/**
|
||||||
|
* Show the form for creating a new invitation.
|
||||||
|
*/
|
||||||
|
public function create(Request $request, Dynamic $dynamic)
|
||||||
|
{
|
||||||
|
// Authorize - only owners can view the invite page!
|
||||||
|
$this->authorize('update', $dynamic);
|
||||||
|
|
||||||
|
return Inertia::render('Dynamics/Invite', [
|
||||||
|
'dynamic' => $dynamic,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created invitation in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Dynamic $dynamic)
|
||||||
|
{
|
||||||
|
// 1. Authorize - only owners can send invitations!
|
||||||
|
$isOwner = $dynamic->participants()
|
||||||
|
->where('user_id', $request->user()->id)
|
||||||
|
->where('role', 'owner')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (!$isOwner) {
|
||||||
|
abort(403, 'Only dynamic owners can invite other users.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate
|
||||||
|
$request->validate([
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'role' => ['required', 'string', 'in:owner,participant,editor,viewer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$email = $request->input('email');
|
||||||
|
$role = $request->input('role');
|
||||||
|
|
||||||
|
// Check if user is already a participant of this dynamic
|
||||||
|
$isParticipant = $dynamic->participants()->where('email', $email)->exists();
|
||||||
|
if ($isParticipant) {
|
||||||
|
return redirect()->back()->withErrors([
|
||||||
|
'email' => 'This user is already a participant of this dynamic.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is an active pending invitation for this user
|
||||||
|
$hasPendingInvite = $dynamic->invitations()
|
||||||
|
->where('email', $email)
|
||||||
|
->where('expires_at', '>', now())
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasPendingInvite) {
|
||||||
|
return redirect()->back()->withErrors([
|
||||||
|
'email' => 'An active invitation is already pending for this email address.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create Invitation
|
||||||
|
$invitation = $dynamic->invitations()->create([
|
||||||
|
'email' => $email,
|
||||||
|
'role' => $role,
|
||||||
|
'token' => Str::random(40),
|
||||||
|
'expires_at' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Send Email
|
||||||
|
Mail::to($email)->send(new DynamicInvitationMail($invitation, $request->user()->name));
|
||||||
|
|
||||||
|
return redirect()->back()->with('success', 'Invitation successfully sent!');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept the specified invitation.
|
||||||
|
*/
|
||||||
|
public function accept(Request $request, string $token)
|
||||||
|
{
|
||||||
|
// Must be signed!
|
||||||
|
if (!$request->hasValidSignature()) {
|
||||||
|
abort(401, 'Invalid or expired signature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invitation = DynamicInvitation::where('token', $token)->firstOrFail();
|
||||||
|
|
||||||
|
if ($invitation->isExpired()) {
|
||||||
|
abort(403, 'This invitation has expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the logged in user's email matches the invitation's email!
|
||||||
|
// "Only the user with the specified email address should be able to access the link."
|
||||||
|
if ($request->user()->email !== $invitation->email) {
|
||||||
|
abort(403, 'This invitation was sent to a different email address.');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($request, $invitation) {
|
||||||
|
// Attach user to dynamic as a participant with the specified role
|
||||||
|
$dynamic = $invitation->dynamic;
|
||||||
|
$dynamic->participants()->attach($request->user()->id, ['role' => $invitation->role]);
|
||||||
|
|
||||||
|
// Log to Dynamic chat activity log!
|
||||||
|
$dynamic->chat->messages()->create([
|
||||||
|
'user_id' => null,
|
||||||
|
'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role),
|
||||||
|
'subject_id' => $request->user()->id,
|
||||||
|
'subject_type' => \App\Models\User::class,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Delete the invitation record
|
||||||
|
$invitation->delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect()->route('dynamics.show', $invitation->dynamic_id)->with('success', 'Successfully joined the dynamic!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Http\Requests\StoreLedgerRequest;
|
use App\Http\Requests\StoreLedgerRequest;
|
||||||
use App\Models\Dynamic;
|
use App\Models\Dynamic;
|
||||||
use App\Models\Ledger;
|
use App\Models\Ledger;
|
||||||
|
use App\Services\ActivityService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@@ -24,9 +25,13 @@ class LedgerController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Show the form for creating a new resource.
|
* Show the form for creating a new resource.
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create(Request $request, Dynamic $dynamic)
|
||||||
{
|
{
|
||||||
//
|
$this->authorize('update', $dynamic);
|
||||||
|
|
||||||
|
return Inertia::render('Ledgers/Create', [
|
||||||
|
'dynamic' => $dynamic,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,10 +58,12 @@ class LedgerController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(Request $request, Dynamic $dynamic, Ledger $ledger)
|
public function show(Request $request, Dynamic $dynamic, Ledger $ledger, ActivityService $activityService)
|
||||||
{
|
{
|
||||||
$this->authorize('view', $ledger);
|
$this->authorize('view', $ledger);
|
||||||
|
|
||||||
|
$activityService->updateCursor($request->user(), $ledger);
|
||||||
|
|
||||||
$dynamic->load('chat', 'participants');
|
$dynamic->load('chat', 'participants');
|
||||||
|
|
||||||
$ledger->load([
|
$ledger->load([
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\PredefinedMutation;
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class PredefinedMutationController extends Controller
|
||||||
|
{
|
||||||
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(Dynamic $dynamic)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $dynamic);
|
||||||
|
|
||||||
|
return Inertia::render('Dynamics/PredefinedMutations/Index', [
|
||||||
|
'dynamic' => $dynamic,
|
||||||
|
'predefined_mutations' => $dynamic->predefinedMutations()->latest()->get(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*/
|
||||||
|
public function store(Request $request, Dynamic $dynamic)
|
||||||
|
{
|
||||||
|
$this->authorize('update', $dynamic);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'amount' => ['required', 'integer'],
|
||||||
|
'type' => ['required', 'string', 'in:reward,penalty'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dynamic->predefinedMutations()->create($request->all());
|
||||||
|
|
||||||
|
return redirect()->route('dynamics.predefined-mutations.index', $dynamic);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,14 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
'unreadNotificationsCount' => function () use ($request) {
|
||||||
|
if (! $request->user()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = app(\App\Services\ActivityService::class);
|
||||||
|
return count($service->getUnreadEntitiesGrouped($request->user()));
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class StoreLedgerRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
$dynamic = $this->route('dynamic');
|
$dynamic = $this->route('dynamic');
|
||||||
|
|
||||||
return $dynamic && $this->user()->can('view', $dynamic);
|
return $dynamic && $this->user()->can('update', $dynamic);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,6 +27,7 @@ class StoreLedgerRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'rules' => ['nullable', 'string'],
|
'rules' => ['nullable', 'string'],
|
||||||
|
'alignment' => ['required', 'string', 'in:positive,neutral,negative'],
|
||||||
'media' => ['nullable', 'array'],
|
'media' => ['nullable', 'array'],
|
||||||
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
|
'media.*' => ['file', 'mimes:jpg,jpeg,png,gif,mp4,mov,avi,webm', 'max:20480'],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
|
||||||
|
class UpdateDynamicRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
$dynamic = $this->route('dynamic');
|
||||||
|
|
||||||
|
return $dynamic && $this->user()->can('update', $dynamic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'rules' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\DynamicInvitation;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
class DynamicInvitationMail extends Mailable {
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public DynamicInvitation $invitation, public string $inviterName) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function envelope(): Envelope {
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Invitation to Join Dynamic: ' . $this->invitation->dynamic->name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content {
|
||||||
|
$acceptUrl = URL::temporarySignedRoute(
|
||||||
|
'dynamics.invitations.accept',
|
||||||
|
$this->invitation->expires_at,
|
||||||
|
['token' => $this->invitation->token]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Content(
|
||||||
|
markdown: 'emails.dynamics.invitation',
|
||||||
|
with: [
|
||||||
|
'acceptUrl' => $acceptUrl,
|
||||||
|
'dynamicName' => $this->invitation->dynamic->name,
|
||||||
|
'role' => $this->invitation->role,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ class Dynamic extends Model
|
|||||||
return $this->hasMany(Ledger::class);
|
return $this->hasMany(Ledger::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function invitations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(DynamicInvitation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function chat(): MorphOne
|
public function chat(): MorphOne
|
||||||
{
|
{
|
||||||
return $this->morphOne(Chat::class, 'chatable');
|
return $this->morphOne(Chat::class, 'chatable');
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class DynamicInvitation extends Model {
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'dynamic_id',
|
||||||
|
'email',
|
||||||
|
'role',
|
||||||
|
'token',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function dynamic(): BelongsTo {
|
||||||
|
return $this->belongsTo(Dynamic::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool {
|
||||||
|
return $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ class Ledger extends Model
|
|||||||
'name',
|
'name',
|
||||||
'rules',
|
'rules',
|
||||||
'score',
|
'score',
|
||||||
|
'alignment',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function dynamic(): BelongsTo
|
public function dynamic(): BelongsTo
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
class Message extends Model
|
class Message extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<MessageFactory> */
|
/** @use HasFactory<MessageFactory> */
|
||||||
@@ -16,6 +18,8 @@ class Message extends Model
|
|||||||
'chat_id',
|
'chat_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'content',
|
'content',
|
||||||
|
'subject_id',
|
||||||
|
'subject_type',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function chat(): BelongsTo
|
public function chat(): BelongsTo
|
||||||
@@ -28,6 +32,11 @@ class Message extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function subject(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
|
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
|
||||||
{
|
{
|
||||||
return $this->morphMany(Media::class, 'mediable');
|
return $this->morphMany(Media::class, 'mediable');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Mutation extends Model
|
|||||||
'amount',
|
'amount',
|
||||||
'description',
|
'description',
|
||||||
'status',
|
'status',
|
||||||
|
'predefined_mutation_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function ledger(): BelongsTo
|
public function ledger(): BelongsTo
|
||||||
@@ -32,6 +33,11 @@ class Mutation extends Model
|
|||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function predefinedMutation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PredefinedMutation::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function chat(): MorphOne
|
public function chat(): MorphOne
|
||||||
{
|
{
|
||||||
return $this->morphOne(Chat::class, 'chatable');
|
return $this->morphOne(Chat::class, 'chatable');
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PredefinedMutation extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'dynamic_id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'amount',
|
||||||
|
'type',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function dynamic(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Dynamic::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class ReadCursor extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'cursorable_id',
|
||||||
|
'cursorable_type',
|
||||||
|
'read_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'read_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cursorable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,11 @@ class User extends Authenticatable implements PasskeyUser
|
|||||||
return $this->hasMany(Mutation::class);
|
return $this->hasMany(Mutation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function readCursors()
|
||||||
|
{
|
||||||
|
return $this->hasMany(ReadCursor::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class DynamicPolicy
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, Dynamic $dynamic): bool
|
public function update(User $user, Dynamic $dynamic): bool
|
||||||
{
|
{
|
||||||
return false;
|
return $dynamic->participants()->where('user_id', $user->id)->where('role', 'owner')->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
use App\Models\Mutation;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Models\ReadCursor;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ActivityService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the read cursor for a user on a specific entity.
|
||||||
|
*/
|
||||||
|
public function updateCursor(User $user, $entity): void
|
||||||
|
{
|
||||||
|
ReadCursor::updateOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'cursorable_id' => $entity->id,
|
||||||
|
'cursorable_type' => get_class($entity),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'read_at' => Carbon::now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the read cursor timestamp for a user on a specific entity.
|
||||||
|
*/
|
||||||
|
public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface
|
||||||
|
{
|
||||||
|
$cursor = ReadCursor::where([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'cursorable_id' => $entity->id,
|
||||||
|
'cursorable_type' => get_class($entity),
|
||||||
|
])->first();
|
||||||
|
|
||||||
|
// If no cursor exists, default to epoch (all activities are unread)
|
||||||
|
return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createMessage($chat, $user, $content, $subject = null)
|
||||||
|
{
|
||||||
|
$message = $chat->messages()->create([
|
||||||
|
'user_id' => $user ? $user->id : null,
|
||||||
|
'content' => $content,
|
||||||
|
'subject_id' => $subject ? $subject->id : null,
|
||||||
|
'subject_type' => $subject ? get_class($subject) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createMutation($ledger, $user, $type, $amount, $description, $status)
|
||||||
|
{
|
||||||
|
$mutation = $ledger->mutations()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => $type,
|
||||||
|
'amount' => $amount,
|
||||||
|
'description' => $description,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $mutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all activities for a given entity.
|
||||||
|
*/
|
||||||
|
public function getActivitiesForEntity($entity): array
|
||||||
|
{
|
||||||
|
if ($entity instanceof Dynamic) {
|
||||||
|
$chatId = $entity->chat->id;
|
||||||
|
} elseif ($entity instanceof Ledger) {
|
||||||
|
$chatId = $entity->dynamic->chat->id;
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = Message::where('chat_id', $chatId)
|
||||||
|
->with(['user', 'subject'])
|
||||||
|
->latest()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $messages->map(function ($message) {
|
||||||
|
$messageData = $message->toArray();
|
||||||
|
$messageData['url'] = $this->getUrlForMessage($message);
|
||||||
|
return $messageData;
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user.
|
||||||
|
*/
|
||||||
|
public function getUnreadEntitiesGrouped(User $user): array
|
||||||
|
{
|
||||||
|
$groupedEntities = [];
|
||||||
|
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
|
||||||
|
|
||||||
|
$entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
|
||||||
|
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$readAt = $this->getCursorReadAt($user, $entity);
|
||||||
|
$activities = $this->getActivitiesForEntity($entity);
|
||||||
|
|
||||||
|
$this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groupedEntities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition activities into read and unread, and construct the grouped entity metadata.
|
||||||
|
*/
|
||||||
|
private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void
|
||||||
|
{
|
||||||
|
$alreadyRead = [];
|
||||||
|
$unread = [];
|
||||||
|
|
||||||
|
foreach ($activities as $act) {
|
||||||
|
if (Carbon::parse($act['created_at'])->gt($readAt)) {
|
||||||
|
$unread[] = $act;
|
||||||
|
} else {
|
||||||
|
$alreadyRead[] = $act;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($unread)) {
|
||||||
|
$context = array_slice($alreadyRead, 0, 2);
|
||||||
|
|
||||||
|
$groupedEntities[] = [
|
||||||
|
'id' => $entity->id,
|
||||||
|
'name' => $entity->name,
|
||||||
|
'type' => Str::afterLast($type, '\\'),
|
||||||
|
'url' => $url,
|
||||||
|
'unread_count' => count($unread),
|
||||||
|
'context_activities' => $context,
|
||||||
|
'new_activities' => $unread,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getUrlForEntity($entity): string
|
||||||
|
{
|
||||||
|
if ($entity instanceof Dynamic) {
|
||||||
|
return route('dynamics.show', $entity->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($entity instanceof Ledger) {
|
||||||
|
return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getUrlForMessage(Message $message): string
|
||||||
|
{
|
||||||
|
if ($message->subject) {
|
||||||
|
return $this->getUrlForEntity($message->subject);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($message->chat->chatable) {
|
||||||
|
return $this->getUrlForEntity($message->chat->chatable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-5
@@ -10,22 +10,27 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__ . '/../routes/web.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__ . '/../routes/console.php',
|
||||||
channels: __DIR__.'/../routes/channels.php',
|
channels: __DIR__ . '/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware
|
||||||
|
->web(append: [
|
||||||
HandleAppearance::class,
|
HandleAppearance::class,
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
AddLinkHeadersForPreloadedAssets::class,
|
AddLinkHeadersForPreloadedAssets::class,
|
||||||
|
])
|
||||||
|
->trustProxies(at: [
|
||||||
|
'172.16.0.0/12',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
fn(Request $request) => $request->is('api/*'),
|
||||||
);
|
);
|
||||||
})->create();
|
})->create();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class LedgerFactory extends Factory
|
|||||||
]),
|
]),
|
||||||
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
|
'rules' => 'Scores are added by Doms and subtracted for protocol breaches.',
|
||||||
'score' => 0,
|
'score' => 0,
|
||||||
|
'alignment' => 'neutral',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ledgers', function (Blueprint $table) {
|
||||||
|
$table->string('alignment')->default('neutral')->after('rules');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ledgers', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('alignment');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('read_cursors', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->morphs('cursorable');
|
||||||
|
$table->timestamp('read_at');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['user_id', 'cursorable_id', 'cursorable_type']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('read_cursors');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void {
|
||||||
|
Schema::create('dynamic_invitations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('role');
|
||||||
|
$table->string('token')->unique();
|
||||||
|
$table->timestamp('expires_at');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void {
|
||||||
|
Schema::dropIfExists('dynamic_invitations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('messages', function (Blueprint $table) {
|
||||||
|
$table->foreignId('user_id')->nullable()->change();
|
||||||
|
$table->nullableMorphs('subject');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('messages', function (Blueprint $table) {
|
||||||
|
$table->foreignId('user_id')->nullable(false)->change();
|
||||||
|
$table->dropMorphs('subject');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('predefined_mutations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('dynamic_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->integer('amount');
|
||||||
|
$table->string('type')->default('reward');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('predefined_mutations');
|
||||||
|
}
|
||||||
|
};
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('mutations', function (Blueprint $table) {
|
||||||
|
$table->foreignId('predefined_mutation_id')->nullable()->constrained()->onDelete('set null');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mutations', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['predefined_mutation_id']);
|
||||||
|
$table->dropColumn('predefined_mutation_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ use App\Models\Dynamic;
|
|||||||
use App\Models\Ledger;
|
use App\Models\Ledger;
|
||||||
use App\Models\Mutation;
|
use App\Models\Mutation;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
|
use App\Services\ActivityService;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -16,9 +17,9 @@ class DatabaseSeeder extends Seeder
|
|||||||
/**
|
/**
|
||||||
* Seed the application's database.
|
* Seed the application's database.
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(ActivityService $activityService): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () {
|
DB::transaction(function () use ($activityService) {
|
||||||
// 1. Create Core Users
|
// 1. Create Core Users
|
||||||
$testUser = User::factory()->create([
|
$testUser = User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
@@ -52,7 +53,9 @@ class DatabaseSeeder extends Seeder
|
|||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
$velvetSanctuary = Dynamic::create([
|
$velvetSanctuary = Dynamic::create([
|
||||||
'name' => 'The Velvet Sanctuary',
|
'name' => 'The Velvet Sanctuary',
|
||||||
'rules' => "1. Respect limits and boundaries at all times.\n2. Submit daily logs for curfew and chores.\n3. Maintain proper protocol in the general discussion.",
|
'rules' => "1. Respect limits and boundaries at all times.
|
||||||
|
2. Submit daily logs for curfew and chores.
|
||||||
|
3. Maintain proper protocol in the general discussion.",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
|
// Add participants (Test User is owner, Alice is owner, Bob is submissive/participant)
|
||||||
@@ -64,29 +67,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
$velvetChat = $velvetSanctuary->chat;
|
$velvetChat = $velvetSanctuary->chat;
|
||||||
|
|
||||||
// Seed Dynamic Chat Messages
|
// Seed Dynamic Chat Messages
|
||||||
Message::create([
|
$activityService->createMessage($velvetChat, $alice, 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.');
|
||||||
'chat_id' => $velvetChat->id,
|
$activityService->createMessage($velvetChat, $testUser, 'I will review the curfew log later today.');
|
||||||
'user_id' => $alice->id,
|
$activityService->createMessage($velvetChat, $bob, "Yes, Sir. Yes, Ma'am. I am starting on the chores now.");
|
||||||
'content' => 'Good morning everyone. Bob, please ensure the Obsidian room is polished before 4 PM.',
|
$activityService->createMessage($velvetChat, $testUser, 'Excellent. Keep up the high standard.');
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $velvetChat->id,
|
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'content' => 'I will review the curfew log later today.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $velvetChat->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'content' => "Yes, Sir. Yes, Ma'am. I am starting on the chores now.",
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $velvetChat->id,
|
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'content' => 'Excellent. Keep up the high standard.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add Ledgers
|
// Add Ledgers
|
||||||
$curfewLedger = Ledger::create([
|
$curfewLedger = Ledger::create([
|
||||||
@@ -94,6 +78,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Curfew Compliance',
|
'name' => 'Curfew Compliance',
|
||||||
'rules' => 'Must be in bed and checked in by 11:00 PM.',
|
'rules' => 'Must be in bed and checked in by 11:00 PM.',
|
||||||
'score' => 35,
|
'score' => 35,
|
||||||
|
'alignment' => 'positive',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cleaningLedger = Ledger::create([
|
$cleaningLedger = Ledger::create([
|
||||||
@@ -101,155 +86,49 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Dungeon Cleaning',
|
'name' => 'Dungeon Cleaning',
|
||||||
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
|
'rules' => 'Earn +10 to +20 points for cleaning/setup. Penalty -10 for disorganization.',
|
||||||
'score' => 45,
|
'score' => 45,
|
||||||
|
'alignment' => 'neutral',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$etiquetteLedger = Ledger::create([
|
$etiquetteLedger = Ledger::create([
|
||||||
'dynamic_id' => $velvetSanctuary->id,
|
'dynamic_id' => $velvetSanctuary->id,
|
||||||
'name' => 'Protocol & Etiquette',
|
'name' => 'Protocol & Etiquette',
|
||||||
'rules' => 'Address others by correct titles. -5 per infraction.',
|
'rules' => 'Address others by correct titles. +5 per infraction.',
|
||||||
'score' => -15,
|
'score' => 15,
|
||||||
|
'alignment' => 'negative',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seed Curfew Mutations
|
// Seed Curfew Mutations
|
||||||
Mutation::create([
|
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:45 PM on Friday', 'approved');
|
||||||
'ledger_id' => $curfewLedger->id,
|
$activityService->createMutation($curfewLedger, $bob, 'reward', 15, 'Checked in by 10:30 PM on Saturday', 'approved');
|
||||||
'user_id' => $bob->id,
|
$activityService->createMutation($curfewLedger, $bob, 'reward', 10, 'Checked in by 10:50 PM on Sunday', 'approved');
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 10,
|
|
||||||
'description' => 'Checked in by 10:45 PM on Friday',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $curfewLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 15,
|
|
||||||
'description' => 'Checked in by 10:30 PM on Saturday',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $curfewLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 10,
|
|
||||||
'description' => 'Checked in by 10:50 PM on Sunday',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Seed Cleaning Mutations
|
// Seed Cleaning Mutations
|
||||||
Mutation::create([
|
$activityService->createMutation($cleaningLedger, $bob, 'reward', 15, 'Deep cleaning of the main chamber', 'approved');
|
||||||
'ledger_id' => $cleaningLedger->id,
|
$activityService->createMutation($cleaningLedger, $bob, 'reward', 20, 'Arranged gear rack and polished leather accessories', 'approved');
|
||||||
'user_id' => $bob->id,
|
$activityService->createMutation($cleaningLedger, $bob, 'reward', 10, 'Mopped obsidian floors', 'approved');
|
||||||
'type' => 'reward',
|
$activityService->createMutation($cleaningLedger, $alice, 'penalty', -10, 'Left keys in the locks unmonitored', 'approved');
|
||||||
'amount' => 15,
|
|
||||||
'description' => 'Deep cleaning of the main chamber',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $cleaningLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 20,
|
|
||||||
'description' => 'Arranged gear rack and polished leather accessories',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $cleaningLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 10,
|
|
||||||
'description' => 'Mopped obsidian floors',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $cleaningLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'penalty',
|
|
||||||
'amount' => -10,
|
|
||||||
'description' => 'Left keys in the locks unmonitored',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Seed Pending Mutation with its own Chat Messages!
|
// Seed Pending Mutation with its own Chat Messages!
|
||||||
$pendingMutation = Mutation::create([
|
$pendingMutation = $activityService->createMutation($cleaningLedger, $bob, 'addition', 10, 'Weekly chore submission - dusting shelves', 'pending');
|
||||||
'ledger_id' => $cleaningLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'addition',
|
|
||||||
'amount' => 10,
|
|
||||||
'description' => 'Weekly chore submission - dusting shelves',
|
|
||||||
'status' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Pending mutation chat messages (chat is auto-created on booted)
|
|
||||||
$pendingMutationChat = $pendingMutation->chat;
|
$pendingMutationChat = $pendingMutation->chat;
|
||||||
|
|
||||||
Message::create([
|
$activityService->createMessage($pendingMutationChat, $bob, 'I have finished the shelves. Please approve when convenient.');
|
||||||
'chat_id' => $pendingMutationChat->id,
|
$activityService->createMessage($pendingMutationChat, $alice, "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.");
|
||||||
'user_id' => $bob->id,
|
$activityService->createMessage($pendingMutationChat, $bob, 'Apologies, Ma\'am. I will re-wipe the top section immediately!');
|
||||||
'content' => 'I have finished the shelves. Please approve when convenient.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $pendingMutationChat->id,
|
|
||||||
'user_id' => $alice->id,
|
|
||||||
'content' => "I checked them; there is still some dust on the top shelf. I'll leave this pending until it's perfect.",
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $pendingMutationChat->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'content' => 'Apologies, Ma\'am. I will re-wipe the top section immediately!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Seed Etiquette Mutations
|
// Seed Etiquette Mutations
|
||||||
Mutation::create([
|
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Interrupted Domina Alice during daily instructions', 'approved');
|
||||||
'ledger_id' => $etiquetteLedger->id,
|
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 10, 'Forgot correct posture during morning roll call', 'approved');
|
||||||
'user_id' => $bob->id,
|
$activityService->createMutation($etiquetteLedger, $alice, 'penalty', 5, 'Spoke out of turn in general chat', 'approved');
|
||||||
'type' => 'penalty',
|
$activityService->createMutation($etiquetteLedger, $bob, 'reward', -5, 'Excellent reciting of the house codes', 'approved');
|
||||||
'amount' => -5,
|
|
||||||
'description' => 'Interrupted Domina Alice during daily instructions',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $etiquetteLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'penalty',
|
|
||||||
'amount' => -10,
|
|
||||||
'description' => 'Forgot correct posture during morning roll call',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $etiquetteLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'penalty',
|
|
||||||
'amount' => -5,
|
|
||||||
'description' => 'Spoke out of turn in general chat',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $etiquetteLedger->id,
|
|
||||||
'user_id' => $bob->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 5,
|
|
||||||
'description' => 'Excellent reciting of the house codes',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// 3. Seed Dynamic 2: Obsidian Household Agreement
|
// 3. Seed Dynamic 2: Obsidian Household Agreement
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
$obsidianHousehold = Dynamic::create([
|
$obsidianHousehold = Dynamic::create([
|
||||||
'name' => 'Obsidian Household Agreement',
|
'name' => 'Obsidian Household Agreement',
|
||||||
'rules' => "1. All residents must do their fair share of maintenance.\n2. Coffee machine must be refilled immediately when empty.",
|
'rules' => "1. All residents must do their fair share of maintenance.
|
||||||
|
2. Coffee machine must be refilled immediately when empty.",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
|
$obsidianHousehold->participants()->attach($alice->id, ['role' => 'owner']);
|
||||||
@@ -258,29 +137,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
|
|
||||||
$obsidianChat = $obsidianHousehold->chat;
|
$obsidianChat = $obsidianHousehold->chat;
|
||||||
|
|
||||||
Message::create([
|
$activityService->createMessage($obsidianChat, $alice, "Who finished the coffee beans and didn't put them on the shopping list?");
|
||||||
'chat_id' => $obsidianChat->id,
|
$activityService->createMessage($obsidianChat, $charles, "Wasn't me, I only drink tea.");
|
||||||
'user_id' => $alice->id,
|
$activityService->createMessage($obsidianChat, $testUser, 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.');
|
||||||
'content' => "Who finished the coffee beans and didn't put them on the shopping list?",
|
$activityService->createMessage($obsidianChat, $alice, 'Thank you, Test User. Appreciate the honesty.');
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $obsidianChat->id,
|
|
||||||
'user_id' => $charles->id,
|
|
||||||
'content' => "Wasn't me, I only drink tea.",
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $obsidianChat->id,
|
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'content' => 'My apologies! I did refill the hopper, but forgot to list the replacement bag. I will buy a new pack tonight.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Message::create([
|
|
||||||
'chat_id' => $obsidianChat->id,
|
|
||||||
'user_id' => $alice->id,
|
|
||||||
'content' => 'Thank you, Test User. Appreciate the honesty.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add Ledgers
|
// Add Ledgers
|
||||||
$kitchenLedger = Ledger::create([
|
$kitchenLedger = Ledger::create([
|
||||||
@@ -288,6 +148,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Kitchen Chores',
|
'name' => 'Kitchen Chores',
|
||||||
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
|
'rules' => 'Scores for dishwashing, trash duty, and deep oven cleaning.',
|
||||||
'score' => 40,
|
'score' => 40,
|
||||||
|
'alignment' => 'positive',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$coffeeLedger = Ledger::create([
|
$coffeeLedger = Ledger::create([
|
||||||
@@ -295,36 +156,15 @@ class DatabaseSeeder extends Seeder
|
|||||||
'name' => 'Coffee Machine Maintenance',
|
'name' => 'Coffee Machine Maintenance',
|
||||||
'rules' => ' refill beans and descale monthly.',
|
'rules' => ' refill beans and descale monthly.',
|
||||||
'score' => 10,
|
'score' => 10,
|
||||||
|
'alignment' => 'neutral',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Seed Chores Mutations
|
// Seed Chores Mutations
|
||||||
Mutation::create([
|
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 25, 'Emptied and loaded the dishwasher', 'approved');
|
||||||
'ledger_id' => $kitchenLedger->id,
|
$activityService->createMutation($kitchenLedger, $testUser, 'reward', 15, 'Took out recycling and trash bags', 'approved');
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 25,
|
|
||||||
'description' => 'Emptied and loaded the dishwasher',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Mutation::create([
|
|
||||||
'ledger_id' => $kitchenLedger->id,
|
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 15,
|
|
||||||
'description' => 'Took out recycling and trash bags',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Seed Coffee Mutations
|
// Seed Coffee Mutations
|
||||||
Mutation::create([
|
$activityService->createMutation($coffeeLedger, $testUser, 'reward', 10, 'Descaled and refilled coffee beans', 'approved');
|
||||||
'ledger_id' => $coffeeLedger->id,
|
|
||||||
'user_id' => $testUser->id,
|
|
||||||
'type' => 'reward',
|
|
||||||
'amount' => 10,
|
|
||||||
'description' => 'Descaled and refilled coffee beans',
|
|
||||||
'status' => 'approved',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
@import 'tw-animate-css';
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@import './components.css';
|
||||||
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||||
@source '../../storage/framework/views/*.php';
|
@source '../../storage/framework/views/*.php';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
BEM UI Components Index Manifest (Tailwind CSS v4)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@import './components/heading.css';
|
||||||
|
@import './components/input-error.css';
|
||||||
|
@import './components/text-link.css';
|
||||||
|
@import './components/app-logo.css';
|
||||||
|
@import './components/user-info.css';
|
||||||
|
@import './components/sidebar-header.css';
|
||||||
|
@import './components/app-content.css';
|
||||||
|
@import './components/appearance-tabs.css';
|
||||||
|
@import './components/password-input.css';
|
||||||
|
@import './components/auth-layout.css';
|
||||||
|
@import './components/chat.css';
|
||||||
|
@import './components/lightbox.css';
|
||||||
|
@import './components/invite-form.css';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* 7. AppContent Layout Component */
|
||||||
|
.app-content {
|
||||||
|
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* 4. AppLogo Component */
|
||||||
|
.app-logo__icon-container {
|
||||||
|
@apply flex aspect-square size-8 items-center justify-center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--sidebar-primary);
|
||||||
|
color: var(--sidebar-primary-foreground);
|
||||||
|
|
||||||
|
.app-logo__icon {
|
||||||
|
@apply size-5 fill-current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo__text-container {
|
||||||
|
@apply ml-1 grid flex-1 text-left text-sm;
|
||||||
|
|
||||||
|
.app-logo__text {
|
||||||
|
@apply mb-0.5 truncate leading-tight font-semibold;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* 8. AppearanceTabs Component */
|
||||||
|
.appearance-tabs {
|
||||||
|
@apply inline-flex gap-1 p-1;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--muted);
|
||||||
|
|
||||||
|
.appearance-tabs__tab {
|
||||||
|
@apply flex cursor-pointer items-center px-3.5 py-1.5 transition-colors;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.appearance-tabs__tab--active {
|
||||||
|
background-color: var(--card);
|
||||||
|
color: var(--card-foreground);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance-tabs__icon {
|
||||||
|
@apply -ml-1 h-4 w-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appearance-tabs__label {
|
||||||
|
@apply ml-1.5 text-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/* 10. AuthSimpleLayout Component */
|
||||||
|
.auth-layout {
|
||||||
|
@apply flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10;
|
||||||
|
background-color: var(--background);
|
||||||
|
|
||||||
|
.auth-layout__container {
|
||||||
|
@apply w-full max-w-sm;
|
||||||
|
|
||||||
|
.auth-layout__inner {
|
||||||
|
@apply flex flex-col gap-8;
|
||||||
|
|
||||||
|
.auth-layout__header {
|
||||||
|
@apply flex flex-col items-center gap-4;
|
||||||
|
|
||||||
|
.auth-layout__logo-link {
|
||||||
|
@apply flex flex-col items-center gap-2 font-medium;
|
||||||
|
|
||||||
|
.auth-layout__logo-box {
|
||||||
|
@apply mb-1 flex h-9 w-9 items-center justify-center;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--sidebar-primary);
|
||||||
|
|
||||||
|
.auth-layout__logo {
|
||||||
|
@apply size-9 fill-current;
|
||||||
|
color: var(--sidebar-primary-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout__title-box {
|
||||||
|
@apply space-y-2 text-center;
|
||||||
|
|
||||||
|
.auth-layout__title {
|
||||||
|
@apply text-xl font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-layout__description {
|
||||||
|
@apply text-center text-sm;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/* 11. Chat Component */
|
||||||
|
.c-chat {
|
||||||
|
@apply mt-8;
|
||||||
|
|
||||||
|
.c-chat__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__list {
|
||||||
|
@apply mt-4 flex flex-col gap-3;
|
||||||
|
|
||||||
|
.c-chat__message {
|
||||||
|
@apply overflow-hidden p-4 shadow-sm sm:rounded-lg;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
|
&.c-chat__message--system {
|
||||||
|
@apply w-full self-center border-0 bg-transparent p-0 shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.c-chat__message--own {
|
||||||
|
@apply self-end rounded-br-none text-right;
|
||||||
|
max-width: 80%;
|
||||||
|
background-color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
|
||||||
|
.c-chat__message-header {
|
||||||
|
@apply flex-row-reverse;
|
||||||
|
}
|
||||||
|
.c-chat__message-author {
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
.c-chat__message-time {
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.c-chat__message-text {
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.c-chat__message--other {
|
||||||
|
@apply self-start rounded-bl-none;
|
||||||
|
max-width: 80%;
|
||||||
|
background-color: var(--muted);
|
||||||
|
border-color: var(--border);
|
||||||
|
|
||||||
|
.c-chat__message-author {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
.c-chat__message-time {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.c-chat__message-text {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__system-inner {
|
||||||
|
@apply mx-auto flex w-fit items-center justify-center gap-2 px-3 py-1.5 text-xs;
|
||||||
|
background-color: var(--muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
|
||||||
|
.c-chat__system-icon {
|
||||||
|
@apply size-3.5 shrink-0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__system-text {
|
||||||
|
@apply font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__system-time {
|
||||||
|
@apply shrink-0 text-[10px];
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__message-header {
|
||||||
|
@apply flex justify-between;
|
||||||
|
|
||||||
|
.c-chat__message-author {
|
||||||
|
@apply font-semibold;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__message-time {
|
||||||
|
@apply text-xs;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__message-text {
|
||||||
|
@apply mt-2 text-sm;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__message-media {
|
||||||
|
@apply mt-3 flex flex-wrap gap-2;
|
||||||
|
|
||||||
|
.c-chat__media-item {
|
||||||
|
@apply relative max-w-[240px] overflow-hidden bg-black;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
|
||||||
|
.c-chat__image {
|
||||||
|
@apply h-auto max-h-[180px] w-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__video {
|
||||||
|
@apply h-auto max-h-[180px] w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__play-overlay {
|
||||||
|
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__empty {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
|
||||||
|
.c-chat__form-group {
|
||||||
|
@apply space-y-2;
|
||||||
|
|
||||||
|
.c-chat__label {
|
||||||
|
@apply block text-sm font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__textarea {
|
||||||
|
@apply mt-1 block w-full shadow-sm;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__error {
|
||||||
|
@apply text-sm;
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__attachment-container {
|
||||||
|
@apply mt-2 flex items-center;
|
||||||
|
|
||||||
|
.c-chat__attach-btn {
|
||||||
|
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs transition-colors;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__attach-icon {
|
||||||
|
@apply size-3.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__preview-list {
|
||||||
|
@apply mt-2 flex flex-wrap gap-2;
|
||||||
|
|
||||||
|
.c-chat__preview-item {
|
||||||
|
@apply relative inline-flex items-center gap-2 p-1.5 pr-8 text-xs;
|
||||||
|
border-radius: calc(var(--radius) - 2px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--muted);
|
||||||
|
|
||||||
|
.c-chat__preview-name {
|
||||||
|
@apply max-w-[150px] truncate;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__preview-remove {
|
||||||
|
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px];
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-chat__submit-box {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
|
||||||
|
.c-chat__button {
|
||||||
|
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* 1. Heading Component */
|
||||||
|
.c-heading {
|
||||||
|
@apply mb-8 space-y-0.5;
|
||||||
|
|
||||||
|
&.c-heading--small {
|
||||||
|
@apply mb-0 space-y-0;
|
||||||
|
|
||||||
|
.c-heading__title {
|
||||||
|
@apply mb-0.5 text-base font-medium tracking-normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-heading__title {
|
||||||
|
@apply text-xl font-semibold tracking-tight;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-heading__description {
|
||||||
|
@apply text-sm;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/* 2. InputError Component */
|
||||||
|
.c-input-error__message {
|
||||||
|
@apply text-sm;
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* 13. InviteForm Component */
|
||||||
|
.c-invite-form {
|
||||||
|
@apply mt-8;
|
||||||
|
|
||||||
|
.c-invite-form__card {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
.c-invite-form__body {
|
||||||
|
@apply p-6;
|
||||||
|
color: var(--foreground);
|
||||||
|
|
||||||
|
.c-invite-form__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-form__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
|
||||||
|
.c-invite-form__field {
|
||||||
|
@apply block;
|
||||||
|
|
||||||
|
.c-invite-form__label {
|
||||||
|
@apply block text-sm font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-form__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-form__select {
|
||||||
|
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-form__error {
|
||||||
|
@apply text-sm;
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-form__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
|
||||||
|
.c-invite-form__submit-btn {
|
||||||
|
@apply inline-flex items-center border border-transparent px-4 py-2 text-xs font-semibold tracking-widest uppercase transition duration-150 ease-in-out focus:ring-2 focus:outline-none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--primary-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* 12. Reusable Lightbox Component */
|
||||||
|
.c-lightbox {
|
||||||
|
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||||
|
background-color: rgba(0, 0, 0, 0.95);
|
||||||
|
|
||||||
|
.c-lightbox__close {
|
||||||
|
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-lightbox__content {
|
||||||
|
@apply max-h-full max-w-full;
|
||||||
|
|
||||||
|
.c-lightbox__image {
|
||||||
|
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-lightbox__video {
|
||||||
|
@apply max-h-[90vh] max-w-full rounded shadow-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* 9. PasswordInput Component */
|
||||||
|
.password-input {
|
||||||
|
@apply relative;
|
||||||
|
|
||||||
|
.password-input__field {
|
||||||
|
@apply pr-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input__toggle {
|
||||||
|
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-input__icon {
|
||||||
|
@apply size-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* 6. AppSidebarHeader Component */
|
||||||
|
.sidebar-header {
|
||||||
|
@apply flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear md:px-4;
|
||||||
|
border-bottom: 1px solid var(--sidebar-border);
|
||||||
|
background-color: var(--sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header__inner {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-has-data-[collapsible='icon']/sidebar-wrapper:h-12 .sidebar-header {
|
||||||
|
@apply h-12;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/* 3. TextLink Component */
|
||||||
|
.c-text-link {
|
||||||
|
@apply underline underline-offset-4 transition-colors duration-300 ease-out;
|
||||||
|
color: var(--foreground);
|
||||||
|
text-decoration-color: var(--border);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration-color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/* 5. UserInfo Component */
|
||||||
|
.user-info__avatar {
|
||||||
|
@apply h-8 w-8 overflow-hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info__details {
|
||||||
|
@apply grid flex-1 text-left text-sm leading-tight;
|
||||||
|
|
||||||
|
.user-info__name {
|
||||||
|
@apply truncate font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info__email {
|
||||||
|
@apply truncate text-xs;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -19,7 +19,9 @@ configureEcho({
|
|||||||
forceTLS: false,
|
forceTLS: false,
|
||||||
enabledTransports: ['ws', 'wss'],
|
enabledTransports: ['ws', 'wss'],
|
||||||
});
|
});
|
||||||
(window as any).echoConfigured = true;
|
if (typeof window !== 'undefined') {
|
||||||
|
(window as any).echoConfigured = true;
|
||||||
|
}
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||||
|
|
||||||
|
|||||||
@@ -41,55 +41,48 @@ function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-8">
|
<div class="c-add-mutation-form">
|
||||||
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h4 class="c-add-mutation-form__title">Add Mutation</h4>
|
||||||
Add Mutation
|
<form @submit.prevent="submit" class="c-add-mutation-form__form">
|
||||||
</h4>
|
<div class="c-add-mutation-form__field">
|
||||||
<form
|
<label for="amount" class="c-add-mutation-form__label"
|
||||||
@submit.prevent="submit"
|
|
||||||
class="mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
for="amount"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>Amount</label
|
>Amount</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.amount"
|
v-model="form.amount"
|
||||||
id="amount"
|
id="amount"
|
||||||
type="number"
|
type="number"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-add-mutation-form__input"
|
||||||
/>
|
/>
|
||||||
<div v-if="form.errors.amount" class="text-sm text-red-600">
|
<div
|
||||||
|
v-if="form.errors.amount"
|
||||||
|
class="c-add-mutation-form__error"
|
||||||
|
>
|
||||||
{{ form.errors.amount }}
|
{{ form.errors.amount }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="c-add-mutation-form__field">
|
||||||
<label
|
<label for="description" class="c-add-mutation-form__label"
|
||||||
for="description"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>Description</label
|
>Description</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
id="description"
|
id="description"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-add-mutation-form__textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div
|
<div
|
||||||
v-if="form.errors.description"
|
v-if="form.errors.description"
|
||||||
class="text-sm text-red-600"
|
class="c-add-mutation-form__error"
|
||||||
>
|
>
|
||||||
{{ form.errors.description }}
|
{{ form.errors.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media Uploads for Mutations -->
|
<!-- Media Uploads for Mutations -->
|
||||||
<div>
|
<div class="c-add-mutation-form__field">
|
||||||
<label
|
<label class="c-add-mutation-form__label"
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>Attach Proof Media (Photos/Videos)</label
|
>Attach Proof Media (Photos/Videos)</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -97,24 +90,24 @@ function submit() {
|
|||||||
multiple
|
multiple
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
@change="handleMutationFileChange"
|
@change="handleMutationFileChange"
|
||||||
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
|
class="c-add-mutation-form__file-input"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="form.media.length > 0"
|
v-if="form.media.length > 0"
|
||||||
class="mt-2 flex flex-wrap gap-2"
|
class="c-add-mutation-form__media-preview-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(file, index) in form.media"
|
v-for="(file, index) in form.media"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800"
|
class="c-add-mutation-form__media-preview-item"
|
||||||
>
|
>
|
||||||
<span class="max-w-[150px] truncate">{{
|
<span class="c-add-mutation-form__media-preview-name">{{
|
||||||
file.name
|
file.name
|
||||||
}}</span>
|
}}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeMutationFile(index)"
|
@click="removeMutationFile(index)"
|
||||||
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
|
class="c-add-mutation-form__media-preview-remove"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -122,11 +115,11 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="c-add-mutation-form__actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
|
class="c-add-mutation-form__submit-btn"
|
||||||
>
|
>
|
||||||
Add Mutation
|
Add Mutation
|
||||||
</button>
|
</button>
|
||||||
@@ -134,3 +127,67 @@ function submit() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-add-mutation-form {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__title {
|
||||||
|
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__form {
|
||||||
|
@apply mt-6 space-y-6 overflow-hidden bg-white p-6 shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__textarea {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__file-input {
|
||||||
|
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__media-preview-list {
|
||||||
|
@apply mt-2 flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__media-preview-item {
|
||||||
|
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__media-preview-name {
|
||||||
|
@apply max-w-[150px] truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__media-preview-remove {
|
||||||
|
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__error {
|
||||||
|
@apply text-sm text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-add-mutation-form__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -22,11 +22,3 @@ const className = computed(() => props.class);
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.app-content {
|
|
||||||
@apply mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import UserMenuContent from '@/components/UserMenuContent.vue';
|
import UserMenuContent from '@/components/UserMenuContent.vue';
|
||||||
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
import { useCurrentUrl } from '@/composables/useCurrentUrl';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
import { getInitials } from '@/composables/useInitials';
|
import { getInitials } from '@/composables/useInitials';
|
||||||
import { toUrl } from '@/lib/utils';
|
import { toUrl } from '@/lib/utils';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
@@ -238,6 +239,36 @@ const rightNavItems: NavItem[] = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="(page.props as any).unreadNotificationsCount > 0"
|
||||||
|
class="relative"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
:href="route('dashboard')"
|
||||||
|
class="relative flex h-9 w-9 items-center justify-center rounded-full hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 opacity-80"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
class="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-600 text-[10px] font-bold text-white"
|
||||||
|
>
|
||||||
|
{{ page.props.unreadNotificationsCount }}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger :as-child="true">
|
<DropdownMenuTrigger :as-child="true">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -7,26 +7,6 @@ import AppLogoIcon from '@/components/AppLogoIcon.vue';
|
|||||||
<AppLogoIcon class="app-logo__icon" />
|
<AppLogoIcon class="app-logo__icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="app-logo__text-container">
|
<div class="app-logo__text-container">
|
||||||
<span class="app-logo__text">Laravel Starter Kit</span>
|
<span class="app-logo__text">Ledgerrz</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.app-logo__icon-container {
|
|
||||||
@apply flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo__icon {
|
|
||||||
@apply size-5 fill-current text-white dark:text-black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo__text-container {
|
|
||||||
@apply ml-1 grid flex-1 text-left text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-logo__text {
|
|
||||||
@apply mb-0.5 truncate leading-tight font-semibold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Link } from '@inertiajs/vue3';
|
import { Link, router } from '@inertiajs/vue3';
|
||||||
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
|
import { BookOpen, FolderGit2, LayoutGrid, Users } from '@lucide/vue';
|
||||||
|
import { onMounted, onUnmounted } from 'vue';
|
||||||
import AppLogo from '@/components/AppLogo.vue';
|
import AppLogo from '@/components/AppLogo.vue';
|
||||||
import NavFooter from '@/components/NavFooter.vue';
|
import NavFooter from '@/components/NavFooter.vue';
|
||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
@@ -13,11 +14,30 @@ import {
|
|||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
import { index as dynamics } from '@/routes/dynamics';
|
import { index as dynamics } from '@/routes/dynamics';
|
||||||
import type { NavItem } from '@/types';
|
import type { NavItem } from '@/types';
|
||||||
|
|
||||||
|
const { isMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
|
let unregister: () => void;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
unregister = router.on('navigate', () => {
|
||||||
|
if (isMobile.value) {
|
||||||
|
setOpenMobile(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unregister) {
|
||||||
|
unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Dashboard',
|
title: 'Dashboard',
|
||||||
@@ -32,16 +52,16 @@ const mainNavItems: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const footerNavItems: NavItem[] = [
|
const footerNavItems: NavItem[] = [
|
||||||
{
|
// {
|
||||||
title: 'Repository',
|
// title: 'Repository',
|
||||||
href: 'https://github.com/laravel/vue-starter-kit',
|
// href: 'https://github.com/laravel/vue-starter-kit',
|
||||||
icon: FolderGit2,
|
// icon: FolderGit2,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: 'Documentation',
|
// title: 'Documentation',
|
||||||
href: 'https://laravel.com/docs/starter-kits#vue',
|
// href: 'https://laravel.com/docs/starter-kits#vue',
|
||||||
icon: BookOpen,
|
// icon: BookOpen,
|
||||||
},
|
// },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,3 @@ withDefaults(
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
@apply flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header__inner {
|
|
||||||
@apply flex items-center gap-2;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -27,27 +27,3 @@ const tabs = [
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.appearance-tabs {
|
|
||||||
@apply inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appearance-tabs__tab {
|
|
||||||
@apply flex items-center rounded-md px-3.5 py-1.5 text-neutral-500 transition-colors hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appearance-tabs__tab--active {
|
|
||||||
@apply bg-white shadow-xs hover:bg-white dark:bg-neutral-700 dark:text-neutral-100 dark:hover:bg-neutral-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appearance-tabs__icon {
|
|
||||||
@apply -ml-1 h-4 w-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appearance-tabs__label {
|
|
||||||
@apply ml-1.5 text-sm;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { ref, computed } from 'vue';
|
||||||
|
import { useForm, usePage } from '@inertiajs/vue3';
|
||||||
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
|
import { useEcho, echoIsConfigured, configureEcho } from '@laravel/echo-vue';
|
||||||
import { Paperclip } from '@lucide/vue';
|
|
||||||
import { ref } from 'vue';
|
|
||||||
import { route } from 'ziggy-js';
|
import { route } from 'ziggy-js';
|
||||||
|
import { Paperclip, Info } from '@lucide/vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
chat: {
|
chat: {
|
||||||
id: number;
|
id: number;
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
user: { name: string };
|
user: { id: number; name: string };
|
||||||
content: string;
|
content: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
media?: Array<{
|
media?: Array<{
|
||||||
@@ -52,7 +52,6 @@ useEcho(`chats.${props.chat.id}`, 'MessageSent', (e: any) => {
|
|||||||
|
|
||||||
function handleFileChange(event: Event) {
|
function handleFileChange(event: Event) {
|
||||||
const files = (event.target as HTMLInputElement).files;
|
const files = (event.target as HTMLInputElement).files;
|
||||||
|
|
||||||
if (files) {
|
if (files) {
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
form.media.push(files[i]);
|
form.media.push(files[i]);
|
||||||
@@ -64,11 +63,16 @@ function removeFile(index: number) {
|
|||||||
form.media.splice(index, 1);
|
form.media.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUser = computed(() => usePage().props.auth?.user);
|
||||||
|
|
||||||
|
function isOwnMessage(messageUserId: number): boolean {
|
||||||
|
return currentUser.value && currentUser.value.id === messageUserId;
|
||||||
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
form.post(route('chats.messages.store', props.chat.id), {
|
form.post(route('chats.messages.store', props.chat.id), {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
form.reset();
|
form.reset();
|
||||||
|
|
||||||
if (fileInput.value) {
|
if (fileInput.value) {
|
||||||
fileInput.value.value = '';
|
fileInput.value.value = '';
|
||||||
}
|
}
|
||||||
@@ -100,8 +104,20 @@ function closeLightbox() {
|
|||||||
<div
|
<div
|
||||||
v-for="message in chat.messages"
|
v-for="message in chat.messages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
class="c-chat__message"
|
:class="[
|
||||||
|
'c-chat__message',
|
||||||
|
{
|
||||||
|
'c-chat__message--system':
|
||||||
|
message.user.id === 0,
|
||||||
|
'c-chat__message--own': isOwnMessage(message.user.id),
|
||||||
|
'c-chat__message--other': !isOwnMessage(
|
||||||
|
message.user.id,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
|
<!-- Standard User Chat Message -->
|
||||||
|
<template v-if="!message.content.startsWith('System:')">
|
||||||
<div class="c-chat__message-header">
|
<div class="c-chat__message-header">
|
||||||
<span class="c-chat__message-author">{{
|
<span class="c-chat__message-author">{{
|
||||||
message.user.name
|
message.user.name
|
||||||
@@ -144,6 +160,25 @@ function closeLightbox() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Subtle Activity Log System Message -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="c-chat__system-inner">
|
||||||
|
<Info class="c-chat__system-icon" />
|
||||||
|
<span class="c-chat__system-text">
|
||||||
|
{{ message.content.replace(/^System:\s*/, '') }}
|
||||||
|
</span>
|
||||||
|
<span class="c-chat__system-time">
|
||||||
|
{{
|
||||||
|
new Date(message.created_at).toLocaleTimeString(
|
||||||
|
[],
|
||||||
|
{ hour: '2-digit', minute: '2-digit' },
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="chat.messages.length === 0" class="c-chat__empty">
|
<div v-if="chat.messages.length === 0" class="c-chat__empty">
|
||||||
No messages yet.
|
No messages yet.
|
||||||
@@ -157,7 +192,8 @@ function closeLightbox() {
|
|||||||
id="content"
|
id="content"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="c-chat__textarea"
|
class="c-chat__textarea"
|
||||||
placeholder="Type a message..."
|
placeholder="Type a message... (Press Enter to send, Shift+Enter for newline)"
|
||||||
|
@keydown.enter.exact.prevent="submit"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div v-if="form.errors.content" class="c-chat__error">
|
<div v-if="form.errors.content" class="c-chat__error">
|
||||||
{{ form.errors.content }}
|
{{ form.errors.content }}
|
||||||
@@ -215,165 +251,22 @@ function closeLightbox() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Gorgeous Dark Lightbox Modal -->
|
<!-- Gorgeous Dark Lightbox Modal -->
|
||||||
<div
|
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
|
||||||
v-if="activeLightboxUrl"
|
<button @click="closeLightbox" class="c-lightbox__close">✕</button>
|
||||||
class="c-chat__lightbox"
|
<div class="c-lightbox__content" @click.stop>
|
||||||
@click="closeLightbox"
|
|
||||||
>
|
|
||||||
<button @click="closeLightbox" class="c-chat__lightbox-close">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
<div class="c-chat__lightbox-content" @click.stop>
|
|
||||||
<img
|
<img
|
||||||
v-if="activeLightboxType === 'image'"
|
v-if="activeLightboxType === 'image'"
|
||||||
:src="activeLightboxUrl"
|
:src="activeLightboxUrl"
|
||||||
class="c-chat__lightbox-image"
|
class="c-lightbox__image"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-else-if="activeLightboxType === 'video'"
|
v-else-if="activeLightboxType === 'video'"
|
||||||
:src="activeLightboxUrl"
|
:src="activeLightboxUrl"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
class="c-chat__lightbox-video"
|
class="c-lightbox__video"
|
||||||
></video>
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.c-chat {
|
|
||||||
@apply mt-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__title {
|
|
||||||
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__list {
|
|
||||||
@apply mt-4 space-y-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message {
|
|
||||||
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message-header {
|
|
||||||
@apply flex justify-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message-author {
|
|
||||||
@apply font-semibold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message-time {
|
|
||||||
@apply text-xs text-gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message-text {
|
|
||||||
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__message-media {
|
|
||||||
@apply mt-3 flex flex-wrap gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__media-item {
|
|
||||||
@apply relative max-w-[240px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__image {
|
|
||||||
@apply h-auto max-h-[180px] w-full object-cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__video {
|
|
||||||
@apply h-auto max-h-[180px] w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__play-overlay {
|
|
||||||
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__empty {
|
|
||||||
@apply text-gray-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__form {
|
|
||||||
@apply mt-6 space-y-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__form-group {
|
|
||||||
@apply space-y-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__label {
|
|
||||||
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__textarea {
|
|
||||||
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__error {
|
|
||||||
@apply text-sm text-red-600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__attachment-container {
|
|
||||||
@apply mt-2 flex items-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__attach-btn {
|
|
||||||
@apply inline-flex cursor-pointer items-center gap-1.5 text-xs text-neutral-500 transition-colors hover:text-foreground;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__attach-icon {
|
|
||||||
@apply size-3.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__preview-list {
|
|
||||||
@apply mt-2 flex flex-wrap gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__preview-item {
|
|
||||||
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__preview-name {
|
|
||||||
@apply max-w-[150px] truncate;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__preview-remove {
|
|
||||||
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__submit-box {
|
|
||||||
@apply flex items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__button {
|
|
||||||
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lightbox Styling */
|
|
||||||
.c-chat__lightbox {
|
|
||||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__lightbox-close {
|
|
||||||
@apply absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__lightbox-content {
|
|
||||||
@apply max-h-full max-w-full;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__lightbox-image {
|
|
||||||
@apply max-h-[90vh] max-w-full rounded object-contain shadow-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-chat__lightbox-video {
|
|
||||||
@apply max-h-[90vh] max-w-full rounded shadow-lg;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: '',
|
name: '',
|
||||||
rules: '',
|
rules: '',
|
||||||
|
alignment: 'neutral',
|
||||||
media: [] as File[],
|
media: [] as File[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,58 +35,83 @@ function submit() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-8">
|
<div class="c-create-ledger-form">
|
||||||
<div
|
<div class="c-create-ledger-form__card">
|
||||||
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
|
<div class="c-create-ledger-form__body">
|
||||||
>
|
<h3 class="c-create-ledger-form__title">Create a New Ledger</h3>
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
|
||||||
<h3 class="text-lg font-medium">Create a New Ledger</h3>
|
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="mt-6 space-y-6">
|
<form
|
||||||
<div>
|
@submit.prevent="submit"
|
||||||
<label
|
class="c-create-ledger-form__form"
|
||||||
for="name"
|
>
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
<div class="c-create-ledger-form__field">
|
||||||
|
<label for="name" class="c-create-ledger-form__label"
|
||||||
>Name</label
|
>Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-create-ledger-form__input"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="form.errors.name"
|
v-if="form.errors.name"
|
||||||
class="text-sm text-red-600"
|
class="c-create-ledger-form__error"
|
||||||
>
|
>
|
||||||
{{ form.errors.name }}
|
{{ form.errors.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="c-create-ledger-form__field">
|
||||||
<label
|
<label for="rules" class="c-create-ledger-form__label"
|
||||||
for="rules"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>Rules</label
|
>Rules</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.rules"
|
v-model="form.rules"
|
||||||
id="rules"
|
id="rules"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-create-ledger-form__textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div
|
<div
|
||||||
v-if="form.errors.rules"
|
v-if="form.errors.rules"
|
||||||
class="text-sm text-red-600"
|
class="c-create-ledger-form__error"
|
||||||
>
|
>
|
||||||
{{ form.errors.rules }}
|
{{ form.errors.rules }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media Uploads for Ledgers -->
|
<div class="c-create-ledger-form__field">
|
||||||
<div>
|
|
||||||
<label
|
<label
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
for="alignment"
|
||||||
|
class="c-create-ledger-form__label"
|
||||||
|
>Alignment</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="form.alignment"
|
||||||
|
id="alignment"
|
||||||
|
class="c-create-ledger-form__select"
|
||||||
|
>
|
||||||
|
<option value="positive">
|
||||||
|
Positive (Higher Score is Better)
|
||||||
|
</option>
|
||||||
|
<option value="neutral">
|
||||||
|
Neutral (Frictionless / Standard)
|
||||||
|
</option>
|
||||||
|
<option value="negative">
|
||||||
|
Negative (Lower Score is Better / Demerits)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div
|
||||||
|
v-if="form.errors.alignment"
|
||||||
|
class="c-create-ledger-form__error"
|
||||||
|
>
|
||||||
|
{{ form.errors.alignment }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Uploads for Ledgers -->
|
||||||
|
<div class="c-create-ledger-form__field">
|
||||||
|
<label class="c-create-ledger-form__label"
|
||||||
>Attach Cover/Rules Media</label
|
>Attach Cover/Rules Media</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -93,24 +119,25 @@ function submit() {
|
|||||||
multiple
|
multiple
|
||||||
accept="image/*,video/*"
|
accept="image/*,video/*"
|
||||||
@change="handleLedgerFileChange"
|
@change="handleLedgerFileChange"
|
||||||
class="mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100"
|
class="c-create-ledger-form__file-input"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="form.media.length > 0"
|
v-if="form.media.length > 0"
|
||||||
class="mt-2 flex flex-wrap gap-2"
|
class="c-create-ledger-form__media-preview-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(file, index) in form.media"
|
v-for="(file, index) in form.media"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800"
|
class="c-create-ledger-form__media-preview-item"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="c-create-ledger-form__media-preview-name"
|
||||||
|
>{{ file.name }}</span
|
||||||
>
|
>
|
||||||
<span class="max-w-[150px] truncate">{{
|
|
||||||
file.name
|
|
||||||
}}</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="removeLedgerFile(index)"
|
@click="removeLedgerFile(index)"
|
||||||
class="absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500"
|
class="c-create-ledger-form__media-preview-remove"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
@@ -118,11 +145,11 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="c-create-ledger-form__actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
|
class="c-create-ledger-form__submit-btn"
|
||||||
>
|
>
|
||||||
Create Ledger
|
Create Ledger
|
||||||
</button>
|
</button>
|
||||||
@@ -132,3 +159,93 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-create-ledger-form {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__card {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__body {
|
||||||
|
@apply p-6;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__label {
|
||||||
|
@apply block text-sm font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__textarea {
|
||||||
|
@apply mt-1 block w-full rounded-md border shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__select {
|
||||||
|
@apply mt-1 block w-full rounded-md border p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
border-color: var(--border);
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__file-input {
|
||||||
|
@apply mt-1 block w-full text-sm text-neutral-500 file:mr-4 file:rounded-md file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-xs file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__media-preview-list {
|
||||||
|
@apply mt-2 flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__media-preview-item {
|
||||||
|
@apply relative inline-flex items-center gap-2 rounded border border-neutral-200 bg-neutral-100 p-1.5 pr-8 text-xs dark:border-neutral-700 dark:bg-neutral-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__media-preview-name {
|
||||||
|
@apply max-w-[150px] truncate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__media-preview-remove {
|
||||||
|
@apply absolute top-1.5 right-1.5 cursor-pointer text-[10px] text-neutral-400 hover:text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__error {
|
||||||
|
@apply text-sm text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-create-ledger-form__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -20,27 +20,3 @@ withDefaults(defineProps<Props>(), {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.c-heading {
|
|
||||||
@apply mb-8 space-y-0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-heading--small {
|
|
||||||
@apply mb-0 space-y-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-heading__title {
|
|
||||||
@apply text-xl font-semibold tracking-tight;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-heading--small .c-heading__title {
|
|
||||||
@apply mb-0.5 text-base font-medium tracking-normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-heading__description {
|
|
||||||
@apply text-sm text-muted-foreground;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -11,11 +11,3 @@ defineProps<{
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.c-input-error__message {
|
|
||||||
@apply text-sm text-red-600 dark:text-red-500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -8,23 +8,22 @@ defineProps<{
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
alignment: string;
|
||||||
media?: Array<{ id: number; url: string; mime_type: string }>;
|
media?: Array<{ id: number; url: string; mime_type: string }>;
|
||||||
}>;
|
}>;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-8">
|
<div class="c-ledger-list">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="c-ledger-list__header">
|
||||||
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h4 class="c-ledger-list__title">Ledgers</h4>
|
||||||
Ledgers
|
|
||||||
</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<ul class="c-ledger-list__grid">
|
||||||
<li
|
<li
|
||||||
v-for="ledger in ledgers"
|
v-for="ledger in ledgers"
|
||||||
:key="ledger.id"
|
:key="ledger.id"
|
||||||
class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700"
|
class="c-ledger-list__item"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
:href="
|
:href="
|
||||||
@@ -34,40 +33,140 @@ defineProps<{
|
|||||||
})
|
})
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<h5 class="text-lg font-semibold">
|
<h5 class="c-ledger-list__item-name">
|
||||||
{{ ledger.name }}
|
{{ ledger.name }}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p class="c-ledger-list__item-score">
|
||||||
Score: {{ ledger.score }}
|
Score: {{ ledger.score }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Ledger Alignment Badge -->
|
||||||
|
<div class="c-ledger-list__alignment-wrapper">
|
||||||
|
<span
|
||||||
|
v-if="ledger.alignment === 'positive'"
|
||||||
|
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--positive"
|
||||||
|
>
|
||||||
|
▲ Higher is Better
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="ledger.alignment === 'negative'"
|
||||||
|
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--negative"
|
||||||
|
>
|
||||||
|
▼ Lower is Better
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="c-ledger-list__alignment-badge c-ledger-list__alignment-badge--neutral"
|
||||||
|
>
|
||||||
|
◆ Neutral
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ledger Media Thumbnails -->
|
<!-- Ledger Media Thumbnails -->
|
||||||
<div
|
<div
|
||||||
v-if="ledger.media && ledger.media.length > 0"
|
v-if="ledger.media && ledger.media.length > 0"
|
||||||
class="mt-2 flex flex-wrap gap-1"
|
class="c-ledger-list__media-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in ledger.media"
|
v-for="item in ledger.media"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600"
|
class="c-ledger-list__media-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.mime_type.startsWith('image/')"
|
v-if="item.mime_type.startsWith('image/')"
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="size-full object-cover"
|
class="c-ledger-list__media-img"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-else-if="item.mime_type.startsWith('video/')"
|
v-else-if="item.mime_type.startsWith('video/')"
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="size-full object-cover"
|
class="c-ledger-list__media-video"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="ledgers.length === 0" class="mt-4 text-gray-500">
|
<div v-if="ledgers.length === 0" class="c-ledger-list__empty">
|
||||||
No ledgers found for this dynamic.
|
No ledgers found for this dynamic.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-ledger-list {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__header {
|
||||||
|
@apply mb-6 flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__grid {
|
||||||
|
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__item {
|
||||||
|
@apply p-6;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__item-name {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__item-score {
|
||||||
|
@apply mt-2 text-sm;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__alignment-wrapper {
|
||||||
|
@apply mt-2 flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__alignment-badge {
|
||||||
|
@apply rounded px-1.5 py-0.5 text-[10px] font-semibold tracking-wide uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__alignment-badge--positive {
|
||||||
|
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__alignment-badge--negative {
|
||||||
|
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__alignment-badge--neutral {
|
||||||
|
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__media-list {
|
||||||
|
@apply mt-2 flex flex-wrap gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__media-item {
|
||||||
|
@apply relative size-8 overflow-hidden rounded border border-gray-300 bg-black dark:border-gray-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__media-img {
|
||||||
|
@apply size-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__media-video {
|
||||||
|
@apply size-full object-cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-list__empty {
|
||||||
|
@apply mt-4 text-gray-500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Chat from '@/components/Chat.vue';
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
dynamicId: number;
|
dynamicId: number;
|
||||||
ledgerId: number;
|
ledgerId: number;
|
||||||
|
ledgerAlignment?: string;
|
||||||
mutations: Array<{
|
mutations: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -44,22 +45,40 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
|
|
||||||
return participant?.pivot?.role === 'owner';
|
return participant?.pivot?.role === 'owner';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAmountClass(amount: number): string {
|
||||||
|
const alignment = props.ledgerAlignment || 'neutral';
|
||||||
|
|
||||||
|
if (alignment === 'positive') {
|
||||||
|
return amount > 0
|
||||||
|
? 'c-mutation-list__item-amount--positive'
|
||||||
|
: 'c-mutation-list__item-amount--negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alignment === 'negative') {
|
||||||
|
// Lower is better: negative amount is positive/favorable, positive amount is negative/unfavorable
|
||||||
|
return amount < 0
|
||||||
|
? 'c-mutation-list__item-amount--positive'
|
||||||
|
: 'c-mutation-list__item-amount--negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neutral alignment
|
||||||
|
return 'c-mutation-list__item-amount--neutral';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-8">
|
<div class="c-mutation-list">
|
||||||
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h4 class="c-mutation-list__title">Mutations</h4>
|
||||||
Mutations
|
<ul class="c-mutation-list__list">
|
||||||
</h4>
|
|
||||||
<ul class="mt-4 space-y-4">
|
|
||||||
<li
|
<li
|
||||||
v-for="mutation in mutations"
|
v-for="mutation in mutations"
|
||||||
:key="mutation.id"
|
:key="mutation.id"
|
||||||
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
|
class="c-mutation-list__item"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="c-mutation-list__item-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-semibold">
|
<span class="c-mutation-list__item-author">
|
||||||
{{
|
{{
|
||||||
isOwnerUser(mutation.user_id)
|
isOwnerUser(mutation.user_id)
|
||||||
? 'Added by'
|
? 'Added by'
|
||||||
@@ -67,13 +86,10 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
}}
|
}}
|
||||||
{{ mutation.user.name }}
|
{{ mutation.user.name }}
|
||||||
</span>
|
</span>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="c-mutation-list__item-meta">
|
||||||
<span
|
<span
|
||||||
:class="{
|
:class="getAmountClass(mutation.amount)"
|
||||||
'text-green-500': mutation.amount > 0,
|
class="c-mutation-list__item-amount"
|
||||||
'text-red-500': mutation.amount < 0,
|
|
||||||
}"
|
|
||||||
class="text-sm font-bold"
|
|
||||||
>
|
>
|
||||||
{{ mutation.amount > 0 ? '+' : ''
|
{{ mutation.amount > 0 ? '+' : ''
|
||||||
}}{{ mutation.amount }}
|
}}{{ mutation.amount }}
|
||||||
@@ -82,59 +98,57 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
<span
|
<span
|
||||||
v-if="!isOwnerUser(mutation.user_id)"
|
v-if="!isOwnerUser(mutation.user_id)"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400':
|
'c-mutation-list__item-status--pending':
|
||||||
mutation.status === 'pending',
|
mutation.status === 'pending',
|
||||||
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400':
|
'c-mutation-list__item-status--approved':
|
||||||
mutation.status === 'approved',
|
mutation.status === 'approved',
|
||||||
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400':
|
'c-mutation-list__item-status--rejected':
|
||||||
mutation.status === 'rejected',
|
mutation.status === 'rejected',
|
||||||
}"
|
}"
|
||||||
class="rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
|
class="c-mutation-list__item-status"
|
||||||
>
|
>
|
||||||
{{ mutation.status }}
|
{{ mutation.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500">
|
<div class="c-mutation-list__item-time">
|
||||||
{{ new Date(mutation.created_at).toLocaleString() }}
|
{{ new Date(mutation.created_at).toLocaleString() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
<p class="c-mutation-list__item-desc">
|
||||||
{{ mutation.description }}
|
{{ mutation.description }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Attached Mutation Proof Media -->
|
<!-- Attached Mutation Proof Media -->
|
||||||
<div
|
<div
|
||||||
v-if="mutation.media && mutation.media.length > 0"
|
v-if="mutation.media && mutation.media.length > 0"
|
||||||
class="mt-3 flex flex-wrap gap-2"
|
class="c-mutation-list__media-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in mutation.media"
|
v-for="item in mutation.media"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
|
class="c-mutation-list__media-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.mime_type.startsWith('image/')"
|
v-if="item.mime_type.startsWith('image/')"
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
|
class="c-mutation-list__media-img"
|
||||||
@click="
|
@click="
|
||||||
emit('open-lightbox', item.url, item.mime_type)
|
emit('open-lightbox', item.url, item.mime_type)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else-if="item.mime_type.startsWith('video/')"
|
v-else-if="item.mime_type.startsWith('video/')"
|
||||||
class="relative cursor-pointer transition-opacity hover:opacity-90"
|
class="c-mutation-list__media-video-wrapper"
|
||||||
@click="
|
@click="
|
||||||
emit('open-lightbox', item.url, item.mime_type)
|
emit('open-lightbox', item.url, item.mime_type)
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="h-auto max-h-[150px] w-full"
|
class="c-mutation-list__media-video"
|
||||||
></video>
|
></video>
|
||||||
<div
|
<div class="c-mutation-list__media-video-overlay">
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
|
|
||||||
>
|
|
||||||
▶
|
▶
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,17 +158,17 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
<!-- Owner Approve/Reject Actions -->
|
<!-- Owner Approve/Reject Actions -->
|
||||||
<div
|
<div
|
||||||
v-if="isOwner && mutation.status === 'pending'"
|
v-if="isOwner && mutation.status === 'pending'"
|
||||||
class="mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700"
|
class="c-mutation-list__actions"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@click="updateStatus(mutation.id, 'approved')"
|
@click="updateStatus(mutation.id, 'approved')"
|
||||||
class="inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500"
|
class="c-mutation-list__approve-btn"
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="updateStatus(mutation.id, 'rejected')"
|
@click="updateStatus(mutation.id, 'rejected')"
|
||||||
class="inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500"
|
class="c-mutation-list__reject-btn"
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</button>
|
</button>
|
||||||
@@ -163,8 +177,120 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
<Chat :chat="mutation.chat" />
|
<Chat :chat="mutation.chat" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-if="mutations.length === 0" class="mt-4 text-gray-500">
|
<div v-if="mutations.length === 0" class="c-mutation-list__empty">
|
||||||
No mutations found for this ledger.
|
No mutations found for this ledger.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-mutation-list {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__title {
|
||||||
|
@apply text-lg font-medium text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__list {
|
||||||
|
@apply mt-4 space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item {
|
||||||
|
@apply overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-header {
|
||||||
|
@apply flex items-start justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-author {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-meta {
|
||||||
|
@apply mt-1 flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-amount {
|
||||||
|
@apply text-sm font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-amount--positive {
|
||||||
|
@apply text-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-amount--negative {
|
||||||
|
@apply text-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-amount--neutral {
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-status {
|
||||||
|
@apply rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-status--pending {
|
||||||
|
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-status--approved {
|
||||||
|
@apply bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-status--rejected {
|
||||||
|
@apply bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-time {
|
||||||
|
@apply text-xs text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__item-desc {
|
||||||
|
@apply mt-3 text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-list {
|
||||||
|
@apply mt-3 flex flex-wrap gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-item {
|
||||||
|
@apply max-w-[200px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-img {
|
||||||
|
@apply h-auto max-h-[150px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-video-wrapper {
|
||||||
|
@apply relative cursor-pointer transition-opacity hover:opacity-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-video {
|
||||||
|
@apply h-auto max-h-[150px] w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__media-video-overlay {
|
||||||
|
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__actions {
|
||||||
|
@apply mt-3 flex gap-2 border-t border-neutral-100 pt-3 dark:border-neutral-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__approve-btn {
|
||||||
|
@apply inline-flex cursor-pointer items-center rounded bg-green-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-green-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__reject-btn {
|
||||||
|
@apply inline-flex cursor-pointer items-center rounded bg-red-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-red-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-mutation-list__empty {
|
||||||
|
@apply mt-4 text-gray-500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -8,18 +8,41 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mt-8">
|
<div class="c-participants-list">
|
||||||
<h4 class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
<h4 class="c-participants-list__title">Participants</h4>
|
||||||
Participants
|
<ul class="c-participants-list__grid">
|
||||||
</h4>
|
|
||||||
<ul class="mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<li
|
<li
|
||||||
v-for="participant in participants"
|
v-for="participant in participants"
|
||||||
:key="participant.id"
|
:key="participant.id"
|
||||||
class="overflow-hidden bg-white p-4 shadow-sm sm:rounded-lg dark:bg-gray-800"
|
class="c-participants-list__item"
|
||||||
>
|
>
|
||||||
{{ participant.name }}
|
{{ participant.name }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-participants-list {
|
||||||
|
@apply mt-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-participants-list__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-participants-list__grid {
|
||||||
|
@apply mt-4 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-participants-list__item {
|
||||||
|
@apply overflow-hidden p-4;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -40,23 +40,3 @@ defineExpose({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.password-input {
|
|
||||||
@apply relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.password-input__field) {
|
|
||||||
@apply pr-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-input__toggle {
|
|
||||||
@apply absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.password-input__icon {
|
|
||||||
@apply size-4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -23,11 +23,3 @@ defineProps<Props>();
|
|||||||
<slot />
|
<slot />
|
||||||
</Link>
|
</Link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.c-text-link {
|
|
||||||
@apply text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -34,23 +34,3 @@ const showAvatar = computed(
|
|||||||
<span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
|
<span v-if="showEmail" class="user-info__email">{{ user.email }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../css/app.css";
|
|
||||||
|
|
||||||
.user-info__avatar {
|
|
||||||
@apply h-8 w-8 overflow-hidden rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info__details {
|
|
||||||
@apply grid flex-1 text-left text-sm leading-tight;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info__name {
|
|
||||||
@apply truncate font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info__email {
|
|
||||||
@apply truncate text-xs text-muted-foreground;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -32,47 +32,3 @@ defineProps<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
@reference "../../../css/app.css";
|
|
||||||
|
|
||||||
.auth-layout {
|
|
||||||
@apply flex min-h-svh flex-col items-center justify-center gap-6 bg-background p-6 md:p-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__container {
|
|
||||||
@apply w-full max-w-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__inner {
|
|
||||||
@apply flex flex-col gap-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__header {
|
|
||||||
@apply flex flex-col items-center gap-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__logo-link {
|
|
||||||
@apply flex flex-col items-center gap-2 font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__logo-box {
|
|
||||||
@apply mb-1 flex h-9 w-9 items-center justify-center rounded-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__logo {
|
|
||||||
@apply size-9 fill-current text-[var(--foreground)] dark:text-white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__title-box {
|
|
||||||
@apply space-y-2 text-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__title {
|
|
||||||
@apply text-xl font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-layout__description {
|
|
||||||
@apply text-center text-sm text-muted-foreground;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
|
||||||
import { dashboard } from '@/routes';
|
import { dashboard } from '@/routes';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -13,35 +12,265 @@ defineOptions({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
unreadEntities: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'Dynamic' | 'Ledger';
|
||||||
|
url: string;
|
||||||
|
unread_count: number;
|
||||||
|
context_activities: Array<{
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
user: { name: string } | null;
|
||||||
|
subject: { name: string; url: string } | null;
|
||||||
|
created_at: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
new_activities: Array<{
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
user: { name: string } | null;
|
||||||
|
subject: { name: string; url: string } | null;
|
||||||
|
created_at: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function formatTime(isoString: string): string {
|
||||||
|
return new Date(isoString).toLocaleString();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
|
|
||||||
|
<div class="c-dashboard">
|
||||||
|
<div class="c-dashboard__container">
|
||||||
|
<h2 class="c-dashboard__title">Recent Activity</h2>
|
||||||
|
|
||||||
|
<div v-if="unreadEntities.length > 0" class="c-dashboard__grid">
|
||||||
<div
|
<div
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
v-for="entity in unreadEntities"
|
||||||
|
:key="`${entity.type}_${entity.id}`"
|
||||||
|
class="c-dashboard__card"
|
||||||
>
|
>
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
<div class="c-dashboard__card-header">
|
||||||
<div
|
<div class="c-dashboard__entity-meta">
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
<span
|
||||||
|
:class="[
|
||||||
|
'c-dashboard__badge-type',
|
||||||
|
entity.type === 'Dynamic'
|
||||||
|
? 'c-dashboard__badge-type--dynamic'
|
||||||
|
: 'c-dashboard__badge-type--ledger',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<PlaceholderPattern />
|
{{ entity.type }}
|
||||||
|
</span>
|
||||||
|
<span class="c-dashboard__unread-count">
|
||||||
|
{{ entity.unread_count }} New
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Link
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
:href="entity.url"
|
||||||
|
class="c-dashboard__entity-link"
|
||||||
>
|
>
|
||||||
<PlaceholderPattern />
|
<h3 class="c-dashboard__entity-title">
|
||||||
|
{{ entity.name }}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="c-dashboard__activity-list">
|
||||||
|
<!-- Context / Read Activities -->
|
||||||
<div
|
<div
|
||||||
class="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border"
|
v-for="activity in entity.context_activities"
|
||||||
|
:key="activity.id"
|
||||||
|
class="c-dashboard__activity-item c-dashboard__activity-item--read"
|
||||||
>
|
>
|
||||||
<PlaceholderPattern />
|
<Link :href="activity.url" class="block">
|
||||||
|
<div class="c-dashboard__activity-meta">
|
||||||
|
<span class="c-dashboard__activity-user">
|
||||||
|
{{ activity.user?.name || 'System' }}
|
||||||
|
</span>
|
||||||
|
<span class="c-dashboard__activity-time">
|
||||||
|
{{ formatTime(activity.created_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="c-dashboard__activity-desc">
|
||||||
|
{{ activity.content }}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unread Separator Line -->
|
||||||
|
<div
|
||||||
|
v-if="entity.context_activities.length > 0"
|
||||||
|
class="c-dashboard__divider"
|
||||||
|
>
|
||||||
|
<span class="c-dashboard__divider-text"
|
||||||
|
>New Activity Below</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New / Unread Activities -->
|
||||||
|
<div
|
||||||
|
v-for="activity in entity.new_activities"
|
||||||
|
:key="activity.id"
|
||||||
|
class="c-dashboard__activity-item c-dashboard__activity-item--unread"
|
||||||
|
>
|
||||||
|
<Link :href="activity.url" class="block">
|
||||||
|
<div class="c-dashboard__activity-meta">
|
||||||
|
<span class="c-dashboard__activity-user">
|
||||||
|
{{ activity.user?.name || 'System' }}
|
||||||
|
</span>
|
||||||
|
<span class="c-dashboard__activity-time">
|
||||||
|
{{ formatTime(activity.created_at) }}
|
||||||
|
</span>
|
||||||
|
<span class="c-dashboard__new-badge">NEW</span>
|
||||||
|
</div>
|
||||||
|
<p class="c-dashboard__activity-desc">
|
||||||
|
{{ activity.content }}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
</div>
|
||||||
class="relative min-h-[100vh] flex-1 rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border"
|
</div>
|
||||||
>
|
|
||||||
<PlaceholderPattern />
|
<!-- Empty Caught-Up State -->
|
||||||
|
<div v-else class="c-dashboard__empty-state">
|
||||||
|
<div class="c-dashboard__empty-icon">🔒</div>
|
||||||
|
<p class="c-dashboard__empty-text">
|
||||||
|
All chambers are currently quiet.
|
||||||
|
</p>
|
||||||
|
<p class="c-dashboard__empty-subtext">
|
||||||
|
Your records are completely up to date. Masterfully done.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../css/app.css";
|
||||||
|
|
||||||
|
.c-dashboard {
|
||||||
|
@apply px-4 py-8 sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__container {
|
||||||
|
@apply mx-auto max-w-7xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__title {
|
||||||
|
@apply mb-6 text-xl font-bold tracking-tight text-neutral-900 dark:text-neutral-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__grid {
|
||||||
|
@apply grid grid-cols-1 gap-6 md:grid-cols-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__card {
|
||||||
|
@apply flex flex-col overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-neutral-800 dark:bg-neutral-900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__card-header {
|
||||||
|
@apply border-b border-neutral-100 p-6 dark:border-neutral-800/30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__entity-meta {
|
||||||
|
@apply mb-2 flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__badge-type {
|
||||||
|
@apply rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__badge-type--dynamic {
|
||||||
|
@apply bg-purple-100 text-purple-800 dark:bg-purple-950/20 dark:text-purple-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__badge-type--ledger {
|
||||||
|
@apply bg-indigo-100 text-indigo-800 dark:bg-indigo-950/20 dark:text-indigo-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__unread-count {
|
||||||
|
@apply rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-bold tracking-wider text-red-800 uppercase dark:bg-red-950/20 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__entity-link {
|
||||||
|
@apply hover:underline focus:outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__entity-title {
|
||||||
|
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-list {
|
||||||
|
@apply flex-1 space-y-4 p-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-item {
|
||||||
|
@apply rounded-lg border p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-item--read {
|
||||||
|
@apply border-neutral-200 bg-neutral-50/50 opacity-60 dark:border-neutral-800/50 dark:bg-neutral-950/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-item--unread {
|
||||||
|
@apply border-red-200/50 bg-red-50/10 dark:border-red-900/30 dark:bg-red-950/5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-meta {
|
||||||
|
@apply mb-1.5 flex items-center gap-2 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-user {
|
||||||
|
@apply font-semibold text-neutral-800 dark:text-neutral-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-time {
|
||||||
|
@apply text-neutral-400 dark:text-neutral-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__new-badge {
|
||||||
|
@apply ml-auto rounded bg-red-100 px-1 py-0.5 text-[9px] font-bold text-red-600 dark:bg-red-950/30 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__activity-desc {
|
||||||
|
@apply text-sm text-neutral-600 dark:text-neutral-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__divider {
|
||||||
|
@apply relative my-4 flex items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__divider-text {
|
||||||
|
@apply z-10 bg-white px-3 text-[10px] font-bold tracking-widest text-neutral-400 uppercase dark:bg-neutral-900 dark:text-neutral-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__divider::before {
|
||||||
|
content: '';
|
||||||
|
@apply absolute inset-x-0 h-px bg-neutral-200 dark:bg-neutral-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__empty-state {
|
||||||
|
@apply flex min-h-[300px] flex-col items-center justify-center rounded-2xl border border-dashed border-neutral-200 p-12 text-center dark:border-neutral-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__empty-icon {
|
||||||
|
@apply mb-4 text-4xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__empty-text {
|
||||||
|
@apply text-lg font-semibold text-neutral-900 dark:text-neutral-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dashboard__empty-subtext {
|
||||||
|
@apply mt-1 max-w-md text-sm text-neutral-500 dark:text-neutral-500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -26,60 +26,59 @@ function submit() {
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Create Dynamic" />
|
<Head title="Create Dynamic" />
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="c-dynamics-create">
|
||||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div class="c-dynamics-create__container">
|
||||||
<div
|
<div class="c-dynamics-create__card">
|
||||||
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
|
<div class="c-dynamics-create__body">
|
||||||
>
|
<h3 class="c-dynamics-create__title">
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
Create a New Dynamic
|
||||||
<h3 class="text-lg font-medium">Create a New Dynamic</h3>
|
</h3>
|
||||||
|
|
||||||
<form @submit.prevent="submit" class="mt-6 space-y-6">
|
<form
|
||||||
<div>
|
@submit.prevent="submit"
|
||||||
<label
|
class="c-dynamics-create__form"
|
||||||
for="name"
|
>
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
<div class="c-dynamics-create__field">
|
||||||
|
<label for="name" class="c-dynamics-create__label"
|
||||||
>Name</label
|
>Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
id="name"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-dynamics-create__input"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="form.errors.name"
|
v-if="form.errors.name"
|
||||||
class="text-sm text-red-600"
|
class="c-dynamics-create__error"
|
||||||
>
|
>
|
||||||
{{ form.errors.name }}
|
{{ form.errors.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div class="c-dynamics-create__field">
|
||||||
<label
|
<label for="rules" class="c-dynamics-create__label"
|
||||||
for="rules"
|
|
||||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
||||||
>Rules</label
|
>Rules</label
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.rules"
|
v-model="form.rules"
|
||||||
id="rules"
|
id="rules"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600"
|
class="c-dynamics-create__textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div
|
<div
|
||||||
v-if="form.errors.rules"
|
v-if="form.errors.rules"
|
||||||
class="text-sm text-red-600"
|
class="c-dynamics-create__error"
|
||||||
>
|
>
|
||||||
{{ form.errors.rules }}
|
{{ form.errors.rules }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="c-dynamics-create__actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
|
class="c-dynamics-create__submit-btn"
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
</button>
|
</button>
|
||||||
@@ -90,3 +89,59 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-dynamics-create {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__textarea {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__error {
|
||||||
|
@apply text-sm text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-create__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -17,17 +17,15 @@ const breadcrumbs = [
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Dynamics" />
|
<Head title="Dynamics" />
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="c-dynamics-index">
|
||||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div class="c-dynamics-index__container">
|
||||||
<div
|
<div class="c-dynamics-index__card">
|
||||||
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
|
<div class="c-dynamics-index__body">
|
||||||
>
|
<div class="c-dynamics-index__header">
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
<h3 class="c-dynamics-index__title">Your Dynamics</h3>
|
||||||
<div class="mb-6 flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-medium">Your Dynamics</h3>
|
|
||||||
<Link
|
<Link
|
||||||
:href="route('dynamics.create')"
|
:href="route('dynamics.create')"
|
||||||
class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300"
|
class="c-dynamics-index__button"
|
||||||
>
|
>
|
||||||
Create Dynamic
|
Create Dynamic
|
||||||
</Link>
|
</Link>
|
||||||
@@ -35,30 +33,82 @@ const breadcrumbs = [
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="dynamics.length > 0"
|
v-if="dynamics.length > 0"
|
||||||
class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"
|
class="c-dynamics-index__grid"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="dynamic in dynamics"
|
v-for="dynamic in dynamics"
|
||||||
:key="dynamic.id"
|
:key="dynamic.id"
|
||||||
class="border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700"
|
class="c-dynamics-index__item"
|
||||||
>
|
>
|
||||||
<Link :href="route('dynamics.show', dynamic.id)">
|
<Link :href="route('dynamics.show', dynamic.id)">
|
||||||
<h4 class="text-lg font-semibold">
|
<h4 class="c-dynamics-index__item-title">
|
||||||
{{ dynamic.name }}
|
{{ dynamic.name }}
|
||||||
</h4>
|
</h4>
|
||||||
</Link>
|
</Link>
|
||||||
<p
|
<p class="c-dynamics-index__item-desc">
|
||||||
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
{{ dynamic.rules }}
|
{{ dynamic.rules }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<p>You don't have any dynamics yet.</p>
|
<p class="c-dynamics-index__empty">
|
||||||
|
You don't have any dynamics yet.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-dynamics-index {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__header {
|
||||||
|
@apply mb-6 flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__button {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__grid {
|
||||||
|
@apply grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__item {
|
||||||
|
@apply border-b border-gray-200 bg-white p-6 dark:border-gray-600 dark:bg-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__item-title {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__item-desc {
|
||||||
|
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamics-index__empty {
|
||||||
|
@apply text-gray-500 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dynamic: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
email: '',
|
||||||
|
role: 'participant',
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
name: 'Dynamics',
|
||||||
|
href: route('dynamics.index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: props.dynamic.name,
|
||||||
|
href: route('dynamics.show', props.dynamic.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Invite User',
|
||||||
|
href: route('dynamics.invitations.create', props.dynamic.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form.post(route('dynamics.invitations.store', props.dynamic.id), {
|
||||||
|
onSuccess: () => form.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Invite User" />
|
||||||
|
|
||||||
|
<div class="c-invite-user">
|
||||||
|
<div class="c-invite-user__container">
|
||||||
|
<div class="c-invite-user__card">
|
||||||
|
<div class="c-invite-user__body">
|
||||||
|
<h3 class="c-invite-user__title">
|
||||||
|
Invite User to {{ dynamic.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form
|
||||||
|
@submit.prevent="submit"
|
||||||
|
class="c-invite-user__form"
|
||||||
|
>
|
||||||
|
<div class="c-invite-user__field">
|
||||||
|
<label
|
||||||
|
for="email"
|
||||||
|
class="c-invite-user__label"
|
||||||
|
>Email Address</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
class="c-invite-user__input"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="form.errors.email"
|
||||||
|
class="c-invite-user__error"
|
||||||
|
>
|
||||||
|
{{ form.errors.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-invite-user__field">
|
||||||
|
<label
|
||||||
|
for="role"
|
||||||
|
class="c-invite-user__label"
|
||||||
|
>Role</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="form.role"
|
||||||
|
id="role"
|
||||||
|
class="c-invite-user__select"
|
||||||
|
>
|
||||||
|
<option value="owner">Owner (Full Control)</option>
|
||||||
|
<option value="participant">Participant</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
</select>
|
||||||
|
<div
|
||||||
|
v-if="form.errors.role"
|
||||||
|
class="c-invite-user__error"
|
||||||
|
>
|
||||||
|
{{ form.errors.role }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-invite-user__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="c-invite-user__submit-btn"
|
||||||
|
>
|
||||||
|
Send Invitation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-invite-user {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__select {
|
||||||
|
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__error {
|
||||||
|
@apply text-sm text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-invite-user__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import { defineProps } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dynamic: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
predefined_mutations: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type: string;
|
||||||
|
}>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
amount: 0,
|
||||||
|
type: 'reward',
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
name: 'Dynamics',
|
||||||
|
href: route('dynamics.index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: props.dynamic.name,
|
||||||
|
href: route('dynamics.show', props.dynamic.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Predefined Mutations',
|
||||||
|
href: route('dynamics.predefined-mutations.index', props.dynamic.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form.post(route('dynamics.predefined-mutations.store', props.dynamic.id), {
|
||||||
|
onSuccess: () => form.reset(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Predefined Mutations" />
|
||||||
|
|
||||||
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
|
<div class="c-predefined-mutations">
|
||||||
|
<div class="c-predefined-mutations__container">
|
||||||
|
<div class="c-predefined-mutations__card">
|
||||||
|
<div class="c-predefined-mutations__body">
|
||||||
|
<h3 class="c-predefined-mutations__title">
|
||||||
|
Predefined Mutations for {{ dynamic.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__list">
|
||||||
|
<div
|
||||||
|
v-for="mutation in predefined_mutations"
|
||||||
|
:key="mutation.id"
|
||||||
|
class="c-predefined-mutations__item"
|
||||||
|
>
|
||||||
|
<div class="c-predefined-mutations__item-details">
|
||||||
|
<h4 class="c-predefined-mutations__item-name">
|
||||||
|
{{ mutation.name }}
|
||||||
|
</h4>
|
||||||
|
<p class="c-predefined-mutations__item-description">
|
||||||
|
{{ mutation.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="c-predefined-mutations__item-amount">
|
||||||
|
{{ mutation.amount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__card mt-8">
|
||||||
|
<div class="c-predefined-mutations__body">
|
||||||
|
<h3 class="c-predefined-mutations__title">
|
||||||
|
Create New Predefined Mutation
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form
|
||||||
|
@submit.prevent="submit"
|
||||||
|
class="c-predefined-mutations__form"
|
||||||
|
>
|
||||||
|
<div class="c-predefined-mutations__field">
|
||||||
|
<label
|
||||||
|
for="name"
|
||||||
|
class="c-predefined-mutations__label"
|
||||||
|
>Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.name"
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
class="c-predefined-mutations__input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__field">
|
||||||
|
<label
|
||||||
|
for="description"
|
||||||
|
class="c-predefined-mutations__label"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
id="description"
|
||||||
|
rows="4"
|
||||||
|
class="c-predefined-mutations__textarea"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__field">
|
||||||
|
<label
|
||||||
|
for="amount"
|
||||||
|
class="c-predefined-mutations__label"
|
||||||
|
>Amount</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.amount"
|
||||||
|
id="amount"
|
||||||
|
type="number"
|
||||||
|
class="c-predefined-mutations__input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__field">
|
||||||
|
<label
|
||||||
|
for="type"
|
||||||
|
class="c-predefined-mutations__label"
|
||||||
|
>Type</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
v-model="form.type"
|
||||||
|
id="type"
|
||||||
|
class="c-predefined-mutations__select"
|
||||||
|
>
|
||||||
|
<option value="reward">Reward</option>
|
||||||
|
<option value="penalty">Penalty</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-predefined-mutations__actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="form.processing"
|
||||||
|
class="c-predefined-mutations__submit-btn"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../../css/app.css";
|
||||||
|
|
||||||
|
.c-predefined-mutations {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__list {
|
||||||
|
@apply mt-6 space-y-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__item {
|
||||||
|
@apply flex items-center justify-between rounded-lg border p-4 dark:border-gray-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__item-details {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__item-name {
|
||||||
|
@apply font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__item-description {
|
||||||
|
@apply text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__item-amount {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__textarea {
|
||||||
|
@apply mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__select {
|
||||||
|
@apply mt-1 block w-full rounded-md border-gray-300 bg-white p-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-predefined-mutations__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, useForm, Link as InertiaLink } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dynamic: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
rules: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
name: props.dynamic.name,
|
||||||
|
rules: props.dynamic.rules,
|
||||||
|
});
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
name: 'Dynamics',
|
||||||
|
href: route('dynamics.index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: props.dynamic.name,
|
||||||
|
href: route('dynamics.show', props.dynamic.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Settings',
|
||||||
|
href: route('dynamics.edit', props.dynamic.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form.patch(route('dynamics.update', props.dynamic.id));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Dynamic Settings" />
|
||||||
|
|
||||||
|
<div class="c-dynamic-settings">
|
||||||
|
<div class="c-dynamic-settings__container">
|
||||||
|
<div class="c-dynamic-settings__card">
|
||||||
|
<div class="c-dynamic-settings__body">
|
||||||
|
<h3 class="c-dynamic-settings__title">Dynamic Settings</h3>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="c-dynamic-settings__form">
|
||||||
|
<div class="c-dynamic-settings__field">
|
||||||
|
<label for="name" class="c-dynamic-settings__label">Name</label>
|
||||||
|
<input v-model="form.name" id="name" type="text" class="c-dynamic-settings__input" />
|
||||||
|
<div v-if="form.errors.name" class="c-dynamic-settings__error">
|
||||||
|
{{ form.errors.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-dynamic-settings__field">
|
||||||
|
<label for="rules" class="c-dynamic-settings__label">Rules</label>
|
||||||
|
<textarea v-model="form.rules" id="rules" rows="4" class="c-dynamic-settings__textarea"></textarea>
|
||||||
|
<div v-if="form.errors.rules" class="c-dynamic-settings__error">
|
||||||
|
{{ form.errors.rules }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="c-dynamic-settings__actions">
|
||||||
|
<button type="submit" :disabled="form.processing" class="c-dynamic-settings__submit-btn">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<InertiaLink :href="route('dynamics.predefined-mutations.index', dynamic.id)" class="c-dynamic-settings__submit-btn">
|
||||||
|
Manage Predefined Mutations
|
||||||
|
</InertiaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-dynamic-settings {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__form {
|
||||||
|
@apply mt-6 space-y-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__field {
|
||||||
|
@apply block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__label {
|
||||||
|
@apply block text-sm font-medium text-gray-700 dark:text-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__input {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__textarea {
|
||||||
|
@apply mt-1 block w-full rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:focus:border-indigo-600 dark:focus:ring-indigo-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__error {
|
||||||
|
@apply text-sm text-red-600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__actions {
|
||||||
|
@apply flex items-center gap-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-settings__submit-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
|
||||||
import { route } from 'ziggy-js';
|
|
||||||
import Chat from '@/components/Chat.vue';
|
import Chat from '@/components/Chat.vue';
|
||||||
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
|
|
||||||
import LedgerList from '@/components/LedgerList.vue';
|
|
||||||
import ParticipantsList from '@/components/ParticipantsList.vue';
|
import ParticipantsList from '@/components/ParticipantsList.vue';
|
||||||
|
import LedgerList from '@/components/LedgerList.vue';
|
||||||
|
import { Head, Link as InertiaLink } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
dynamic: {
|
dynamic: {
|
||||||
@@ -17,9 +16,11 @@ const props = defineProps<{
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
score: number;
|
score: number;
|
||||||
|
alignment: string;
|
||||||
media?: Array<{ id: number; url: string; mime_type: string }>;
|
media?: Array<{ id: number; url: string; mime_type: string }>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
isOwner: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const breadcrumbs = [
|
const breadcrumbs = [
|
||||||
@@ -37,17 +38,22 @@ const breadcrumbs = [
|
|||||||
<template>
|
<template>
|
||||||
<Head :title="dynamic.name" />
|
<Head :title="dynamic.name" />
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="c-dynamic-show">
|
||||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div class="c-dynamic-show__container">
|
||||||
<div
|
<div class="c-dynamic-show__card">
|
||||||
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
|
<div class="c-dynamic-show__body">
|
||||||
>
|
<div class="flex justify-between items-start">
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
<div>
|
||||||
<h3 class="text-lg font-medium">{{ dynamic.name }}</h3>
|
<h3 class="c-dynamic-show__title">{{ dynamic.name }}</h3>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p class="c-dynamic-show__rules">
|
||||||
{{ dynamic.rules }}
|
{{ dynamic.rules }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<InertiaLink v-if="isOwner" :href="route('dynamics.edit', dynamic.id)" class="c-dynamic-show__settings-btn">
|
||||||
|
Settings
|
||||||
|
</InertiaLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dynamic Chat -->
|
<!-- Dynamic Chat -->
|
||||||
@@ -59,8 +65,55 @@ const breadcrumbs = [
|
|||||||
<!-- Ledgers List Component -->
|
<!-- Ledgers List Component -->
|
||||||
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
|
<LedgerList :dynamic-id="dynamic.id" :ledgers="dynamic.ledgers" />
|
||||||
|
|
||||||
<!-- Create Ledger Form Component -->
|
<div v-if="isOwner" class="mt-8 flex gap-4">
|
||||||
<CreateLedgerForm :dynamic-id="dynamic.id" />
|
<InertiaLink :href="route('dynamics.invitations.create', dynamic.id)" class="c-dynamic-show__action-btn">
|
||||||
|
Invite User
|
||||||
|
</InertiaLink>
|
||||||
|
<InertiaLink :href="route('dynamics.ledgers.create', dynamic.id)" class="c-dynamic-show__action-btn">
|
||||||
|
Create Ledger
|
||||||
|
</InertiaLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-dynamic-show {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__card {
|
||||||
|
@apply overflow-hidden;
|
||||||
|
background-color: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__body {
|
||||||
|
@apply p-6;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__rules {
|
||||||
|
@apply mt-2 text-sm;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__settings-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-dynamic-show__action-btn {
|
||||||
|
@apply inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold tracking-widest text-white uppercase transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none active:bg-gray-900 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-white dark:focus:bg-white dark:focus:ring-offset-gray-800 dark:active:bg-gray-300;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { route } from 'ziggy-js';
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue';
|
||||||
|
import CreateLedgerForm from '@/components/CreateLedgerForm.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
dynamic: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const breadcrumbs = [
|
||||||
|
{
|
||||||
|
name: 'Dynamics',
|
||||||
|
href: route('dynamics.index'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: props.dynamic.name,
|
||||||
|
href: route('dynamics.show', props.dynamic.id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Create Ledger',
|
||||||
|
href: route('dynamics.ledgers.create', props.dynamic.id),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Create Ledger" />
|
||||||
|
|
||||||
|
<div class="py-12">
|
||||||
|
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
||||||
|
<CreateLedgerForm :dynamic-id="dynamic.id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -22,6 +22,7 @@ const props = defineProps<{
|
|||||||
name: string;
|
name: string;
|
||||||
score: number;
|
score: number;
|
||||||
rules: string;
|
rules: string;
|
||||||
|
alignment: string;
|
||||||
media?: Array<{ id: number; url: string; mime_type: string }>;
|
media?: Array<{ id: number; url: string; mime_type: string }>;
|
||||||
mutations: Array<{
|
mutations: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -147,66 +148,83 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
<Head :title="ledger.name" />
|
<Head :title="ledger.name" />
|
||||||
|
|
||||||
<!-- Floating Toast Notifications -->
|
<!-- Floating Toast Notifications -->
|
||||||
<div
|
<div class="c-ledger-show__toast-container">
|
||||||
class="pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-for="toast in toasts"
|
v-for="toast in toasts"
|
||||||
:key="toast.id"
|
:key="toast.id"
|
||||||
class="pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl"
|
class="c-ledger-show__toast-item"
|
||||||
>
|
>
|
||||||
<span>{{ toast.message }}</span>
|
<span>{{ toast.message }}</span>
|
||||||
<button
|
<button
|
||||||
@click="toasts = toasts.filter((t) => t.id !== toast.id)"
|
@click="toasts = toasts.filter((t) => t.id !== toast.id)"
|
||||||
class="cursor-pointer text-neutral-400 hover:text-white"
|
class="c-ledger-show__toast-close-btn"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="c-ledger-show">
|
||||||
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
|
<div class="c-ledger-show__container">
|
||||||
<div
|
<div class="c-ledger-show__card">
|
||||||
class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
|
<div class="c-ledger-show__body">
|
||||||
>
|
<h3 class="c-ledger-show__title">{{ ledger.name }}</h3>
|
||||||
<div class="p-6 text-gray-900 dark:text-gray-100">
|
<p class="c-ledger-show__score">
|
||||||
<h3 class="text-lg font-medium">{{ ledger.name }}</h3>
|
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
Score: {{ ledger.score }}
|
Score: {{ ledger.score }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
<p class="c-ledger-show__rules">
|
||||||
{{ ledger.rules }}
|
{{ ledger.rules }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Ledger Alignment Badge / Subtitle -->
|
||||||
|
<div class="c-ledger-show__alignment-wrapper">
|
||||||
|
<span
|
||||||
|
v-if="ledger.alignment === 'positive'"
|
||||||
|
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--positive"
|
||||||
|
>
|
||||||
|
▲ Positive Alignment — A higher score is better.
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="ledger.alignment === 'negative'"
|
||||||
|
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--negative"
|
||||||
|
>
|
||||||
|
▼ Negative Alignment — A lower score is better
|
||||||
|
(demerits / infractions).
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="c-ledger-show__alignment-badge c-ledger-show__alignment-badge--neutral"
|
||||||
|
>
|
||||||
|
◆ Neutral Alignment — Scorekeeping neutral.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ledger Descriptive Media -->
|
<!-- Ledger Descriptive Media -->
|
||||||
<div
|
<div
|
||||||
v-if="ledger.media && ledger.media.length > 0"
|
v-if="ledger.media && ledger.media.length > 0"
|
||||||
class="mt-4 flex flex-wrap gap-3"
|
class="c-ledger-show__media-list"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in ledger.media"
|
v-for="item in ledger.media"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700"
|
class="c-ledger-show__media-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="item.mime_type.startsWith('image/')"
|
v-if="item.mime_type.startsWith('image/')"
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90"
|
class="c-ledger-show__media-img"
|
||||||
@click="openLightbox(item.url, item.mime_type)"
|
@click="openLightbox(item.url, item.mime_type)"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else-if="item.mime_type.startsWith('video/')"
|
v-else-if="item.mime_type.startsWith('video/')"
|
||||||
class="relative cursor-pointer transition-opacity hover:opacity-90"
|
class="c-ledger-show__media-video-wrapper"
|
||||||
@click="openLightbox(item.url, item.mime_type)"
|
@click="openLightbox(item.url, item.mime_type)"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
:src="item.url"
|
:src="item.url"
|
||||||
class="h-auto max-h-[200px] w-full"
|
class="c-ledger-show__media-video"
|
||||||
></video>
|
></video>
|
||||||
<div
|
<div class="c-ledger-show__media-video-overlay">
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white"
|
|
||||||
>
|
|
||||||
▶
|
▶
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,36 +243,116 @@ function isOwnerUser(userId: number): boolean {
|
|||||||
:mutations="ledger.mutations"
|
:mutations="ledger.mutations"
|
||||||
:participants="dynamic.participants"
|
:participants="dynamic.participants"
|
||||||
:is-owner="isOwner"
|
:is-owner="isOwner"
|
||||||
|
:ledger-alignment="ledger.alignment"
|
||||||
@open-lightbox="openLightbox"
|
@open-lightbox="openLightbox"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Lightbox Modal -->
|
<!-- Lightbox Modal -->
|
||||||
<div
|
<div v-if="activeLightboxUrl" class="c-lightbox" @click="closeLightbox">
|
||||||
v-if="activeLightboxUrl"
|
<button @click="closeLightbox" class="c-lightbox__close">✕</button>
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4"
|
<div class="c-lightbox__content" @click.stop>
|
||||||
@click="closeLightbox"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
@click="closeLightbox"
|
|
||||||
class="absolute top-6 right-6 cursor-pointer text-3xl text-white transition-colors duration-200 hover:text-red-500"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
<div class="max-h-full max-w-full" @click.stop>
|
|
||||||
<img
|
<img
|
||||||
v-if="activeLightboxType === 'image'"
|
v-if="activeLightboxType === 'image'"
|
||||||
:src="activeLightboxUrl"
|
:src="activeLightboxUrl"
|
||||||
class="max-h-[90vh] max-w-full rounded object-contain shadow-lg"
|
class="c-lightbox__image"
|
||||||
/>
|
/>
|
||||||
<video
|
<video
|
||||||
v-else-if="activeLightboxType === 'video'"
|
v-else-if="activeLightboxType === 'video'"
|
||||||
:src="activeLightboxUrl"
|
:src="activeLightboxUrl"
|
||||||
controls
|
controls
|
||||||
autoplay
|
autoplay
|
||||||
class="max-h-[90vh] max-w-full rounded shadow-lg"
|
class="c-lightbox__video"
|
||||||
></video>
|
></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../../../css/app.css";
|
||||||
|
|
||||||
|
.c-ledger-show__toast-container {
|
||||||
|
@apply pointer-events-none fixed top-6 right-6 z-50 flex max-w-sm flex-col gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__toast-item {
|
||||||
|
@apply pointer-events-auto flex items-center justify-between gap-4 rounded-lg border border-neutral-700/50 bg-neutral-900 px-4 py-3 text-sm text-white shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__toast-close-btn {
|
||||||
|
@apply cursor-pointer text-neutral-400 hover:text-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show {
|
||||||
|
@apply py-12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__container {
|
||||||
|
@apply mx-auto max-w-7xl sm:px-6 lg:px-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__card {
|
||||||
|
@apply overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__body {
|
||||||
|
@apply p-6 text-gray-900 dark:text-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__title {
|
||||||
|
@apply text-lg font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__score {
|
||||||
|
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__rules {
|
||||||
|
@apply mt-2 text-sm text-gray-600 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__alignment-wrapper {
|
||||||
|
@apply mt-3 flex items-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__alignment-badge {
|
||||||
|
@apply rounded px-2.5 py-1 text-xs font-semibold tracking-wide uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__alignment-badge--positive {
|
||||||
|
@apply bg-green-50/50 text-green-600 dark:bg-green-950/20 dark:text-green-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__alignment-badge--negative {
|
||||||
|
@apply bg-red-50/50 text-red-600 dark:bg-red-950/20 dark:text-red-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__alignment-badge--neutral {
|
||||||
|
@apply bg-gray-50/50 text-gray-500 dark:bg-neutral-800/20 dark:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-list {
|
||||||
|
@apply mt-4 flex flex-wrap gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-item {
|
||||||
|
@apply max-w-[320px] overflow-hidden rounded-md border border-neutral-200 bg-black dark:border-neutral-700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-img {
|
||||||
|
@apply h-auto max-h-[200px] w-full cursor-pointer object-cover transition-opacity hover:opacity-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-video-wrapper {
|
||||||
|
@apply relative cursor-pointer transition-opacity hover:opacity-90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-video {
|
||||||
|
@apply h-auto max-h-[200px] w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-ledger-show__media-video-overlay {
|
||||||
|
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-2xl font-bold text-white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<x-mail::message>
|
||||||
|
# You have been invited!
|
||||||
|
|
||||||
|
Hello,
|
||||||
|
|
||||||
|
**{{ $inviterName }}** has invited you to join their Dynamic: **{{ $dynamicName }}** as a **{{ strtoupper($role) }}**.
|
||||||
|
|
||||||
|
Only the user registered with this email address can accept this invitation. The invitation link will be valid for 7 days.
|
||||||
|
|
||||||
|
<x-mail::button :url="$acceptUrl">
|
||||||
|
Accept Invitation
|
||||||
|
</x-mail::button>
|
||||||
|
|
||||||
|
If you do not have an account yet, please register using this email address first, then click the button above.
|
||||||
|
|
||||||
|
Thanks,<br>
|
||||||
|
{{ config('app.name') }}
|
||||||
|
</x-mail::message>
|
||||||
+18
-3
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
use App\Http\Controllers\DynamicController;
|
use App\Http\Controllers\DynamicController;
|
||||||
use App\Http\Controllers\LedgerController;
|
use App\Http\Controllers\LedgerController;
|
||||||
use App\Http\Controllers\MessageController;
|
use App\Http\Controllers\MessageController;
|
||||||
@@ -9,14 +10,28 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::inertia('/', 'Welcome')->name('home');
|
Route::inertia('/', 'Welcome')->name('home');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
|
Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
|
|
||||||
|
Route::resource('dynamics', DynamicController::class)->except(['edit', 'update']);
|
||||||
|
Route::get('/dynamics/{dynamic}/settings', [DynamicController::class, 'edit'])->name('dynamics.edit');
|
||||||
|
Route::patch('/dynamics/{dynamic}/settings', [DynamicController::class, 'update'])->name('dynamics.update');
|
||||||
|
|
||||||
|
Route::get('/dynamics/{dynamic}/ledgers/create', [LedgerController::class, 'create'])->name('dynamics.ledgers.create');
|
||||||
|
Route::resource('dynamics.ledgers', LedgerController::class)->scoped()->except(['create']);
|
||||||
|
|
||||||
|
Route::resource('dynamics.predefined-mutations', \App\Http\Controllers\PredefinedMutationController::class)->scoped();
|
||||||
|
|
||||||
Route::resource('dynamics', DynamicController::class);
|
|
||||||
Route::resource('dynamics.ledgers', LedgerController::class)->scoped();
|
|
||||||
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
|
Route::resource('dynamics.ledgers.mutations', MutationController::class)->scoped();
|
||||||
|
|
||||||
|
Route::get('/dynamics/{dynamic}/invitations/create', [\App\Http\Controllers\DynamicInvitationController::class, 'create'])->name('dynamics.invitations.create');
|
||||||
|
Route::post('/dynamics/{dynamic}/invitations', [\App\Http\Controllers\DynamicInvitationController::class, 'store'])->name('dynamics.invitations.store');
|
||||||
|
|
||||||
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
|
Route::post('/chats/{chat}/messages', [MessageController::class, 'store'])->name('chats.messages.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::get('/invitations/accept/{token}', [\App\Http\Controllers\DynamicInvitationController::class, 'accept'])
|
||||||
|
->middleware(['auth', 'signed'])
|
||||||
|
->name('dynamics.invitations.accept');
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Broadcast::routes();
|
\Illuminate\Support\Facades\Broadcast::routes();
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Browser;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
test('user can register, log in, and log out', function () {
|
||||||
|
$this->browse(function (Browser $browser) {
|
||||||
|
// 1. Test Registration
|
||||||
|
$browser->visit('/register')
|
||||||
|
->waitForText('Create an account')
|
||||||
|
->type('name', 'New Browser User')
|
||||||
|
->type('email', 'newbrowseruser@example.com')
|
||||||
|
->type('password', 'password')
|
||||||
|
->type('password_confirmation', 'password')
|
||||||
|
->press('Create account')
|
||||||
|
->waitForLocation('/dashboard')
|
||||||
|
->assertPathIs('/dashboard')
|
||||||
|
->assertSee('New Browser User');
|
||||||
|
|
||||||
|
// 2. Test Logout
|
||||||
|
// Open the user menu (trigger button shows initials or user name)
|
||||||
|
$browser->click('button[aria-haspopup="menu"]')
|
||||||
|
->waitForText('Log out')
|
||||||
|
->clickLink('Log out')
|
||||||
|
->waitForLocation('/login') // Fortify logs out and redirects to login or home
|
||||||
|
->assertPathIs('/login');
|
||||||
|
|
||||||
|
// 3. Test Login
|
||||||
|
$browser->type('email', 'newbrowseruser@example.com')
|
||||||
|
->type('password', 'password')
|
||||||
|
->press('Log in')
|
||||||
|
->waitForLocation('/dashboard')
|
||||||
|
->assertPathIs('/dashboard')
|
||||||
|
->assertSee('New Browser User');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Browser;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
use App\Models\Mutation;
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
test('access control and actions are enforced for owners and participants', function () {
|
||||||
|
// Create database state
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'name' => 'Owner Alice',
|
||||||
|
'email' => 'alice-owner@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$participant = User::factory()->create([
|
||||||
|
'name' => 'Participant Bob',
|
||||||
|
'email' => 'bob-sub@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$outsider = User::factory()->create([
|
||||||
|
'name' => 'Outsider Charlie',
|
||||||
|
'email' => 'charlie-outsider@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dynamic = Dynamic::create([
|
||||||
|
'name' => 'Private Club',
|
||||||
|
'rules' => 'Strict access control.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||||
|
|
||||||
|
$ledger = Ledger::create([
|
||||||
|
'dynamic_id' => $dynamic->id,
|
||||||
|
'name' => 'Rules Compliance',
|
||||||
|
'rules' => 'Score rules.',
|
||||||
|
'score' => 100,
|
||||||
|
'alignment' => 'neutral',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->browse(function (Browser $sessionOwner, Browser $sessionParticipant, Browser $sessionOutsider) use ($dynamic, $ledger, $owner, $participant, $outsider) {
|
||||||
|
|
||||||
|
// 1. Test Outsider trying to access dynamic they DO NOT belong to (should be forbidden / 403)
|
||||||
|
$sessionOutsider->loginAs($outsider)
|
||||||
|
->visit(route('dynamics.show', $dynamic))
|
||||||
|
->assertSee('403') // Laravel / Inertia forbidden page
|
||||||
|
->assertDontSee('Private Club');
|
||||||
|
|
||||||
|
// 2. Test Participant accessing dynamic they DO belong to (should be allowed)
|
||||||
|
$sessionParticipant->loginAs($participant)
|
||||||
|
->visit(route('dynamics.show', $dynamic))
|
||||||
|
->waitForText('Private Club')
|
||||||
|
->assertSee('Private Club')
|
||||||
|
->assertSee('Participant Bob');
|
||||||
|
|
||||||
|
// 3. Test Participant adding a mutation suggestion
|
||||||
|
$sessionParticipant->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
|
||||||
|
->waitForText('Add Mutation')
|
||||||
|
->type('amount', '20')
|
||||||
|
->type('description', 'Cleaned the main room')
|
||||||
|
->press('Add Mutation')
|
||||||
|
->waitForText('PENDING')
|
||||||
|
->assertSee('PENDING') // Mutation should show up as pending
|
||||||
|
->assertDontSee('Approve'); // Standard participant should NOT see approve button!
|
||||||
|
|
||||||
|
// 4. Test Owner logging in, seeing the pending suggestion, and approving it!
|
||||||
|
$sessionOwner->loginAs($owner)
|
||||||
|
->visit(route('dynamics.ledgers.show', [$dynamic, $ledger]))
|
||||||
|
->waitForText('Cleaned the main room')
|
||||||
|
->assertSee('PENDING')
|
||||||
|
->assertSee('Approve') // Owner should see the Approve button!
|
||||||
|
->press('Approve')
|
||||||
|
->waitForText('Score: 120') // Score updated from 100 to 120 after approval!
|
||||||
|
->assertDontSee('PENDING'); // No longer pending!
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Browser;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
use Laravel\Dusk\Browser;
|
||||||
|
use Tests\DuskTestCase;
|
||||||
|
|
||||||
|
test('multiple sessions can communicate in real time through websockets', function () {
|
||||||
|
// 1. Create realistic database state
|
||||||
|
$owner = User::factory()->create([
|
||||||
|
'name' => 'TU Test User',
|
||||||
|
'email' => 'test-owner@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$participant = User::factory()->create([
|
||||||
|
'name' => 'Submissive Bob',
|
||||||
|
'email' => 'test-sub@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dynamic = Dynamic::create([
|
||||||
|
'name' => 'The Test Sanctuary',
|
||||||
|
'rules' => 'Rules for realtime testing.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||||
|
|
||||||
|
// 2. Spawn two separate browser sessions/browsers in parallel
|
||||||
|
$this->browse(function (Browser $sessionA, Browser $sessionB) use ($dynamic, $owner, $participant) {
|
||||||
|
|
||||||
|
// --- SESSION A: Owner ---
|
||||||
|
$sessionA->loginAs($owner)
|
||||||
|
->visit(route('dynamics.show', $dynamic))
|
||||||
|
->waitForText('The Test Sanctuary')
|
||||||
|
->assertSee('TU Test User'); // Verify loaded in as Owner
|
||||||
|
|
||||||
|
// --- SESSION B: Participant ---
|
||||||
|
$sessionB->loginAs($participant)
|
||||||
|
->visit(route('dynamics.show', $dynamic))
|
||||||
|
->waitForText('The Test Sanctuary')
|
||||||
|
->assertSee('Submissive Bob'); // Verify loaded in as Submissive/Participant
|
||||||
|
|
||||||
|
// --- REAL-TIME COMMUNICATING ---
|
||||||
|
// Owner types and sends a message in chat
|
||||||
|
$sessionA->type('#content', 'Hello Submissive Bob, did you complete your daily chores?')
|
||||||
|
->click('.c-chat__button')
|
||||||
|
->waitForText('Hello Submissive Bob');
|
||||||
|
|
||||||
|
// Since websockets broadcast in real-time, Session B receives it without reloading
|
||||||
|
$sessionB->waitForText('Hello Submissive Bob', 5)
|
||||||
|
->assertSee('Hello Submissive Bob, did you complete your daily chores?');
|
||||||
|
|
||||||
|
// Participant replies in real-time
|
||||||
|
$sessionB->type('#content', 'Yes Master, everything is complete and logged in the ledger!')
|
||||||
|
->click('.c-chat__button')
|
||||||
|
->waitForText('Yes Master, everything is complete');
|
||||||
|
|
||||||
|
// Session A receives the reply in real-time without reloading
|
||||||
|
$sessionA->waitForText('Yes Master, everything is complete', 5)
|
||||||
|
->assertSee('Yes Master, everything is complete and logged in the ledger!');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests;
|
||||||
|
|
||||||
|
use Laravel\Dusk\TestCase as BaseTestCase;
|
||||||
|
use Facebook\WebDriver\Chrome\ChromeOptions;
|
||||||
|
use Facebook\WebDriver\Remote\RemoteWebDriver;
|
||||||
|
use Facebook\WebDriver\Remote\DesiredCapabilities;
|
||||||
|
|
||||||
|
abstract class DuskTestCase extends BaseTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Prepare for Dusk test execution.
|
||||||
|
*
|
||||||
|
* @beforeClass
|
||||||
|
*/
|
||||||
|
public static function prepare(): void
|
||||||
|
{
|
||||||
|
if (! static::runningInSail()) {
|
||||||
|
static::startChromeDriver();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the RemoteWebDriver instance.
|
||||||
|
*/
|
||||||
|
protected function driver(): RemoteWebDriver
|
||||||
|
{
|
||||||
|
$options = (new ChromeOptions)->addArguments(collect([
|
||||||
|
$this->shouldStartMaximized() ? '--start-maximized' : '--window-size=1920,1080',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--headless=new',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
])->unless(static::runningInSail(), function (collect $arguments) {
|
||||||
|
return $arguments->push('--disable-smooth-scrolling');
|
||||||
|
})->all());
|
||||||
|
|
||||||
|
return RemoteWebDriver::create(
|
||||||
|
$_ENV['DUSK_DRIVER_URL'] ?? env('DUSK_DRIVER_URL') ?? 'http://localhost:9515',
|
||||||
|
DesiredCapabilities::chrome()->setCapability(ChromeOptions::CAPABILITY, $options)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
use App\Models\Mutation;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Services\ActivityService;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
test('guests are redirected to the login page', function () {
|
test('guests are redirected to the login page', function () {
|
||||||
$response = $this->get(route('dashboard'));
|
$response = $this->get(route('dashboard'));
|
||||||
@@ -13,4 +19,106 @@ test('authenticated users can visit the dashboard', function () {
|
|||||||
|
|
||||||
$response = $this->get(route('dashboard'));
|
$response = $this->get(route('dashboard'));
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page->component('Dashboard')->has('unreadEntities'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting dynamic updates the read cursor', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(ActivityService::class);
|
||||||
|
$initialCursor = $service->getCursorReadAt($user, $dynamic);
|
||||||
|
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
|
||||||
|
|
||||||
|
// Visit Dynamic Show
|
||||||
|
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
|
||||||
|
|
||||||
|
// Re-check cursor is updated to near now
|
||||||
|
$updatedCursor = $service->getCursorReadAt($user, $dynamic);
|
||||||
|
expect($updatedCursor->gt($initialCursor))->toBeTrue();
|
||||||
|
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting ledger updates the read cursor', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$service = app(ActivityService::class);
|
||||||
|
$initialCursor = $service->getCursorReadAt($user, $ledger);
|
||||||
|
expect($initialCursor->toIso8601String())->toBe('1970-01-01T00:00:00+00:00');
|
||||||
|
|
||||||
|
// Visit Ledger Show
|
||||||
|
$this->get(route('dynamics.ledgers.show', [$dynamic->id, $ledger->id]))->assertOk();
|
||||||
|
|
||||||
|
// Re-check cursor is updated to near now
|
||||||
|
$updatedCursor = $service->getCursorReadAt($user, $ledger);
|
||||||
|
expect($updatedCursor->gt($initialCursor))->toBeTrue();
|
||||||
|
expect($updatedCursor->diffInSeconds(Carbon::now()))->toBeLessThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard groups and filters unread entities correctly based on cursor', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create(['name' => 'Testing Dynamic']);
|
||||||
|
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
// Create custom Chat so booted has chat available
|
||||||
|
$dynamic->chat()->create([]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// Create past message (already read)
|
||||||
|
$pastMsg = Message::create([
|
||||||
|
'chat_id' => $dynamic->chat->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'content' => 'Old message context',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Artificially advance cursor to after past message
|
||||||
|
Carbon::setTestNow(Carbon::now()->addMinutes(5));
|
||||||
|
$service = app(ActivityService::class);
|
||||||
|
$service->updateCursor($user, $dynamic);
|
||||||
|
|
||||||
|
// Create new unread message
|
||||||
|
Carbon::setTestNow(Carbon::now()->addMinutes(5));
|
||||||
|
$unreadMsg = Message::create([
|
||||||
|
'chat_id' => $dynamic->chat->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'content' => 'New unread message alert',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Retrieve unread groupings
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
// Verify unread grouping structure
|
||||||
|
$response->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->where('unreadEntities.0.name', 'Testing Dynamic')
|
||||||
|
->where('unreadEntities.0.unread_count', 1)
|
||||||
|
->has('unreadEntities.0.context_activities', 1) // Should have old message as context
|
||||||
|
->where('unreadEntities.0.context_activities.0.content', 'Old message context')
|
||||||
|
->has('unreadEntities.0.new_activities', 1) // Should have unread message
|
||||||
|
->where('unreadEntities.0.new_activities.0.content', 'New unread message alert')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now visit the Dynamic, which clears the unread count
|
||||||
|
$this->get(route('dynamics.show', $dynamic->id))->assertOk();
|
||||||
|
|
||||||
|
// Dashboard should now show 0 unread groups (caught up)
|
||||||
|
$response2 = $this->get(route('dashboard'));
|
||||||
|
$response2->assertOk();
|
||||||
|
$response2->assertInertia(fn ($page) => $page
|
||||||
|
->component('Dashboard')
|
||||||
|
->has('unreadEntities', 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
Carbon::setTestNow(); // Reset test time
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\DynamicInvitation;
|
||||||
|
use App\Mail\DynamicInvitationMail;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
test('only owners can invite other users to a dynamic', function () {
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$participant = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||||
|
|
||||||
|
// 1. Participant tries to send an invite (forbidden)
|
||||||
|
$response = $this->actingAs($participant)
|
||||||
|
->post(route('dynamics.invitations.store', $dynamic), [
|
||||||
|
'email' => 'invitee@example.com',
|
||||||
|
'role' => 'participant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
Mail::assertNothingSent();
|
||||||
|
|
||||||
|
// 2. Owner sends a valid invite (allowed)
|
||||||
|
$response = $this->actingAs($owner)
|
||||||
|
->post(route('dynamics.invitations.store', $dynamic), [
|
||||||
|
'email' => 'invitee@example.com',
|
||||||
|
'role' => 'participant',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
// Verify invitation is stored
|
||||||
|
$invitation = DynamicInvitation::firstWhere('email', 'invitee@example.com');
|
||||||
|
expect($invitation)->not->toBeNull();
|
||||||
|
expect($invitation->role)->toBe('participant');
|
||||||
|
expect($invitation->dynamic_id)->toBe($dynamic->id);
|
||||||
|
|
||||||
|
// Verify email was dispatched
|
||||||
|
Mail::assertSent(DynamicInvitationMail::class, function ($mail) use ($invitation) {
|
||||||
|
return $mail->hasTo($invitation->email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the user with the specified email address can accept the link', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$invitee = User::factory()->create(['email' => 'matching@example.com']);
|
||||||
|
$hijacker = User::factory()->create(['email' => 'hijacker@example.com']);
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
// Create a pending invitation
|
||||||
|
$invitation = $dynamic->invitations()->create([
|
||||||
|
'email' => 'matching@example.com',
|
||||||
|
'role' => 'editor',
|
||||||
|
'token' => 'secure_test_token_123',
|
||||||
|
'expires_at' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate a secure, valid signed URL
|
||||||
|
$signedUrl = URL::temporarySignedRoute(
|
||||||
|
'dynamics.invitations.accept',
|
||||||
|
$invitation->expires_at,
|
||||||
|
['token' => $invitation->token]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Unauthenticated user tries to accept (redirected to login)
|
||||||
|
$response = $this->get($signedUrl);
|
||||||
|
$response->assertRedirect('/login');
|
||||||
|
|
||||||
|
// 2. Different user (hijacker) tries to accept (forbidden / 403)
|
||||||
|
$response = $this->actingAs($hijacker)->get($signedUrl);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
expect($dynamic->participants()->where('user_id', $hijacker->id)->exists())->toBeFalse();
|
||||||
|
|
||||||
|
// 3. Intended user accepts the signed invitation link (success)
|
||||||
|
$response = $this->actingAs($invitee)->get($signedUrl);
|
||||||
|
$response->assertRedirect(route('dynamics.show', $dynamic));
|
||||||
|
|
||||||
|
// Verify invitee is joined as a participant with the specified role
|
||||||
|
$isJoined = $dynamic->participants()
|
||||||
|
->where('user_id', $invitee->id)
|
||||||
|
->wherePivot('role', 'editor')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
expect($isJoined)->toBeTrue();
|
||||||
|
|
||||||
|
// Verify invitation is deleted from the database
|
||||||
|
expect(DynamicInvitation::where('token', 'secure_test_token_123')->exists())->toBeFalse();
|
||||||
|
|
||||||
|
// Verify system notification is added to Dynamic activity chat
|
||||||
|
$chatMessages = $dynamic->chat->messages;
|
||||||
|
expect($chatMessages)->not->toBeEmpty();
|
||||||
|
expect($chatMessages->last()->content)->toBe("{$invitee->name} joined the Dynamic as a EDITOR");
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
|
||||||
|
test('dynamic owners can view ledger creation form and create ledgers', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->actingAs($owner);
|
||||||
|
|
||||||
|
// Can view form
|
||||||
|
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertOk();
|
||||||
|
|
||||||
|
// Can store ledger
|
||||||
|
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
|
||||||
|
'name' => 'Chores Ledger',
|
||||||
|
'rules' => 'Do the tasks.',
|
||||||
|
'alignment' => 'positive',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$response->assertRedirect(route('dynamics.show', $dynamic->id));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('ledgers', [
|
||||||
|
'dynamic_id' => $dynamic->id,
|
||||||
|
'name' => 'Chores Ledger',
|
||||||
|
'alignment' => 'positive',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-owners cannot view ledger creation form or store ledgers', function () {
|
||||||
|
$owner = User::factory()->create();
|
||||||
|
$participant = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
$dynamic->participants()->attach($owner->id, ['role' => 'owner']);
|
||||||
|
$dynamic->participants()->attach($participant->id, ['role' => 'participant']);
|
||||||
|
|
||||||
|
$this->actingAs($participant);
|
||||||
|
|
||||||
|
// Cannot view form
|
||||||
|
$this->get(route('dynamics.ledgers.create', $dynamic->id))->assertStatus(403);
|
||||||
|
|
||||||
|
// Cannot store ledger
|
||||||
|
$response = $this->post(route('dynamics.ledgers.store', $dynamic->id), [
|
||||||
|
'name' => 'Illegal Ledger',
|
||||||
|
'rules' => 'This should fail.',
|
||||||
|
'alignment' => 'positive',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
$this->assertDatabaseMissing('ledgers', [
|
||||||
|
'name' => 'Illegal Ledger',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Dynamic;
|
||||||
|
use App\Models\Ledger;
|
||||||
|
use App\Models\Mutation;
|
||||||
|
use App\Models\Message;
|
||||||
|
use App\Models\Media;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
test('media can be attached to mutations, ledgers, and messages', function () {
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$dynamic = Dynamic::factory()->create();
|
||||||
|
$dynamic->participants()->attach($user->id, ['role' => 'owner']);
|
||||||
|
$ledger = Ledger::factory()->create(['dynamic_id' => $dynamic->id]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
// 1. Test attaching media to a mutation
|
||||||
|
$file1 = UploadedFile::fake()->create('proof1.jpg', 100);
|
||||||
|
$file2 = UploadedFile::fake()->create('proof2.png', 100);
|
||||||
|
|
||||||
|
$response = $this->post(route('dynamics.ledgers.mutations.store', [$dynamic, $ledger]), [
|
||||||
|
'amount' => 50,
|
||||||
|
'description' => 'Completed deep clean',
|
||||||
|
'media' => [$file1, $file2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$response->assertRedirect();
|
||||||
|
|
||||||
|
$mutation = Mutation::firstWhere('description', 'Completed deep clean');
|
||||||
|
expect($mutation)->not->toBeNull();
|
||||||
|
expect($mutation->media)->toHaveCount(2);
|
||||||
|
expect($mutation->media->first()->file_name)->toBe('proof1.jpg');
|
||||||
|
expect($mutation->media->last()->file_name)->toBe('proof2.png');
|
||||||
|
|
||||||
|
Storage::disk('public')->assertExists($mutation->media->first()->file_path);
|
||||||
|
Storage::disk('public')->assertExists($mutation->media->last()->file_path);
|
||||||
|
|
||||||
|
// 2. Test attaching media to a ledger
|
||||||
|
$file3 = UploadedFile::fake()->create('rules.jpg', 100);
|
||||||
|
$response = $this->post(route('dynamics.ledgers.store', $dynamic), [
|
||||||
|
'name' => 'Worship Ledger',
|
||||||
|
'rules' => 'Specific rules',
|
||||||
|
'alignment' => 'neutral',
|
||||||
|
'media' => [$file3],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasNoErrors();
|
||||||
|
$response->assertRedirect();
|
||||||
|
|
||||||
|
$newLedger = Ledger::firstWhere('name', 'Worship Ledger');
|
||||||
|
expect($newLedger)->not->toBeNull();
|
||||||
|
expect($newLedger->media)->toHaveCount(1);
|
||||||
|
expect($newLedger->media->first()->file_name)->toBe('rules.jpg');
|
||||||
|
|
||||||
|
// 3. Test attaching media to a chat message
|
||||||
|
$chat = $dynamic->chat;
|
||||||
|
$file4 = UploadedFile::fake()->create('chat_img.jpg', 100);
|
||||||
|
$response = $this->post(route('chats.messages.store', $chat), [
|
||||||
|
'content' => 'Check this out!',
|
||||||
|
'media' => [$file4],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
|
||||||
|
$message = Message::firstWhere('content', 'Check this out!');
|
||||||
|
expect($message)->not->toBeNull();
|
||||||
|
expect($message->media)->toHaveCount(1);
|
||||||
|
expect($message->media->first()->file_name)->toBe('chat_img.jpg');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user