diff --git a/app/Helpers/AssetHelpers.php b/app/Helpers/AssetHelpers.php index 31de8cda15..895213b2f9 100644 --- a/app/Helpers/AssetHelpers.php +++ b/app/Helpers/AssetHelpers.php @@ -699,3 +699,95 @@ function getRewardLootData($showData, $recipient = 'User', $useCustomSelectize = return $rewardLootData; } + +/********************************************************************************************** + + ATTACHMENTS + Similar functionality to the above rewards but agnostic of recipient and other filters. + +**********************************************************************************************/ + +/** + * Gets the valid attachment types. + * + * @return array + */ +function getAttachmentTypes() { + return [ + 'Item' => 'Item', + 'Currency' => 'Currency', + 'Character' => 'Character', + 'Prompt' => 'Prompt', + 'News' => 'News', + 'Sales' => 'Sales', + 'LootTable' => 'Loot Table', + 'Raffle' => 'Raffle Ticket', + 'Shop' => 'Shop', + ]; +} + +/** + * Gets the attachment data needed for selection blades. + * + * @return array + */ +function getAttachmentData() { + $attachmentTypes = getAttachmentTypes(); + + $attachmentLootData = []; + + // Iterate through each valid key in $attachmentTypes and get the data associated with it + foreach ($attachmentTypes as $attachmentKey => $attachmentType) { + $query = null; + + switch ($attachmentKey) { + case 'Item': + $query = App\Models\Item\Item::orderBy('name'); + break; + case 'Currency': + $query = App\Models\Currency\Currency::query()->orderBy('sort_character', 'DESC'); + break; + case 'LootTable': + $query = App\Models\Loot\LootTable::orderBy('name'); + break; + case 'Raffle': + $query = App\Models\Raffle\Raffle::where('rolled_at', null)->where('is_active', 1)->orderBy('name'); + break; + case 'Character': + $query = App\Models\Character\Character::orderBy('slug'); + break; + case 'Prompt': + $query = App\Models\Prompt\Prompt::orderBy('name'); + break; + case 'News': + $query = App\Models\News::orderBy('title'); + break; + case 'Sales': + $query = App\Models\Sales\Sales::orderBy('title'); + break; + case 'Shop': + $query = App\Models\Shop\Shop::orderBy('name'); + break; + // Add the query builder for your other assets here, set with the matching key in getAttachmentTypes + // If your asset type does not have a model, you may need to add special handling here. + // + // case 'Example': + // $query = \App\Models\Example::orderby('name'); + // break; + } + + $data = $query->get()->mapWithKeys(function ($item) { + return [ + $item->id => json_encode([ + 'name' => $item->title ?? ($item->fullName ?? $item->name), + 'image_url' => $item->imageUrl ?? null, + ]), + ]; + }); + + // Finally, add the data to the array. + $attachmentLootData[$attachmentKey] = $data; + } + + return $attachmentLootData; +} diff --git a/app/Helpers/Helpers.php b/app/Helpers/Helpers.php index 40be9cb59b..97a68a298a 100644 --- a/app/Helpers/Helpers.php +++ b/app/Helpers/Helpers.php @@ -529,3 +529,23 @@ function getRewards($object) { function hasRewards($object) { return App\Models\Reward\Reward::where('object_model', get_class($object))->where('object_id', $object->id)->exists(); } + +/** + * Returns the given objects attachments, if any. + * + * @param mixed $object + * + * @return bool + */ +function getAttachments($object) { + return App\Models\Attachment::where('parent_model', get_class($object))->where('parent_id', $object->id)->get(); +} + +/** + * checks if a certain object has any attachments. + * + * @param mixed $object + */ +function hasAttachments($object) { + return App\Models\Attachment::where('parent_model', get_class($object))->where('parent_id', $object->id)->exists(); +} diff --git a/app/Http/Controllers/Admin/AttachmentController.php b/app/Http/Controllers/Admin/AttachmentController.php new file mode 100644 index 0000000000..a51bea889b --- /dev/null +++ b/app/Http/Controllers/Admin/AttachmentController.php @@ -0,0 +1,31 @@ +only([ + 'parent_model', 'parent_id', 'attachment_type', 'attachment_id', 'data', + ]); + if ($service->editAttachments($data['parent_model'], $data['parent_id'], $data)) { + flash('Attachments updated successfully.')->success(); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } +} diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php new file mode 100644 index 0000000000..bcb0681169 --- /dev/null +++ b/app/Models/Attachment.php @@ -0,0 +1,101 @@ + 'array', + ]; + + /********************************************************************************************** + + RELATIONS + + **********************************************************************************************/ + + /** + * Get the parent object this is attached to. + */ + public function parent() { + return $this->belongsTo($this->parent_model, 'parent_id'); + } + + /** + * Get the attached object. + */ + public function attachment() { + $model = getAssetModelString(strtolower($this->attachment_type)); + + if (!class_exists($model)) { + // Laravel requires a relationship instance to be returned (cannot return null), so returning one that doesn't exist here. + return $this->belongsTo(self::class, 'id', 'attachment_id')->whereNull('attachment_id'); + } + + return $this->belongsTo($model, 'attachment_id'); + } + + /********************************************************************************************** + + ATTRIBUTES + + **********************************************************************************************/ + + /** + * Returns the description attribute or null if not set. + */ + public function getDescriptionAttribute() { + return $this->data['description'] ?? null; + } + + /** + * Returns the parsed description attribute or null if not set. + */ + public function getParsedDescriptionAttribute() { + return $this->data['parsed_description'] ?? null; + } + + /********************************************************************************************** + + OTHER FUNCTIONS + + **********************************************************************************************/ + + /** + * Checks if a certain object has any attachments. + * + * @param mixed $object + */ + public static function hasAttachments($object) { + return self::where('parent_model', get_class($object))->where('parent_id', $object->id)->exists(); + } + + /** + * Get the attachments of a certain object. + * + * @param mixed $object + */ + public static function getAttachments($object) { + return self::where('parent_model', get_class($object))->where('parent_id', $object->id)->get(); + } +} diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php new file mode 100644 index 0000000000..523a3f1c5b --- /dev/null +++ b/app/Services/AttachmentService.php @@ -0,0 +1,108 @@ + 0) { + $attachments->each(function ($attachment) { + $attachment->delete(); + }); + } + + if (count($attachments) > 0) { + flash('Deleted '.count($attachments).' old attachments.')->success(); + } + + if (isset($data['attachment_type'])) { + foreach ($data['attachment_type'] as $key => $type) { + $attributes = [ + 'parent_model' => $parent_model, + 'parent_id' => $parent_id, + 'attachment_type' => $data['attachment_type'][$key], + 'attachment_id' => $data['attachment_id'][$key], + 'data' => null, + ]; + + if (isset($data['data'][$key])) { + // description is special we also store parsed_description + if (isset($data['data'][$key]['description'])) { + $attributes['data']['description'] = $data['data'][$key]['description']; + $attributes['data']['parsed_description'] = parse($data['data'][$key]['description']); + + unset($data['data'][$key]['description']); + } + // additional "special" fields can be mapped here in the future. + + // any other fields are stored as-is + foreach ($data['data'][$key] as $field_key => $field_value) { + $attributes['data'][$field_key] = $field_value; + } + } + + $attachment = new Attachment([ + 'parent_model' => $parent_model, + 'parent_id' => $parent_id, + 'attachment_type' => $data['attachment_type'][$key], + 'attachment_id' => $data['attachment_id'][$key], + 'data' => $attributes['data'] ?? null, + ]); + + if (!$attachment->save()) { + throw new \Exception('Failed to save attachment.'); + } + } + } + + // Log the action + if ($log && !$this->logAdminAction(Auth::user(), 'Edited Attachments', 'Edited '.$parent->displayName.' attachments')) { + throw new \Exception('Failed to log admin action.'); + } + + return $this->commitReturn(true); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } +} diff --git a/database/migrations/2026_01_11_172249_create_attachments_table.php b/database/migrations/2026_01_11_172249_create_attachments_table.php new file mode 100644 index 0000000000..16b69c2363 --- /dev/null +++ b/database/migrations/2026_01_11_172249_create_attachments_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('parent_model'); + $table->unsignedBigInteger('parent_id'); + $table->string('attachment_type'); + $table->unsignedBigInteger('attachment_id'); + + $table->json('data')->nullable()->default(null); // for extensible data storage / extra functionality + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('attachments'); + } +}; diff --git a/resources/views/admin/items/create_edit_item.blade.php b/resources/views/admin/items/create_edit_item.blade.php index 8f74b35010..cf3436aaad 100644 --- a/resources/views/admin/items/create_edit_item.blade.php +++ b/resources/views/admin/items/create_edit_item.blade.php @@ -161,6 +161,10 @@ Add a Tag + @include('widgets._add_attachments', [ + 'object' => $item, + ]) +

Preview

diff --git a/resources/views/admin/prompts/create_edit_prompt.blade.php b/resources/views/admin/prompts/create_edit_prompt.blade.php index 21dedb2f4f..c8aa8e8305 100644 --- a/resources/views/admin/prompts/create_edit_prompt.blade.php +++ b/resources/views/admin/prompts/create_edit_prompt.blade.php @@ -111,6 +111,9 @@ {!! Form::close() !!} @if ($prompt->id) + @include('widgets._add_attachments', [ + 'object' => $prompt, + ]) @include('widgets._add_limits', [ 'object' => $prompt, 'hideAutoUnlock' => true, diff --git a/resources/views/prompts/_prompt_entry.blade.php b/resources/views/prompts/_prompt_entry.blade.php index 61f24f07d4..be57923258 100644 --- a/resources/views/prompts/_prompt_entry.blade.php +++ b/resources/views/prompts/_prompt_entry.blade.php @@ -34,6 +34,13 @@ @elseif($prompt->hide_submissions == 2)

Submissions to this prompt are hidden.

@endif + @if (hasAttachments($prompt)) +
+
Attachments
+ @include('widgets._attachments', [ + 'object' => $prompt, + ]) + @endif

Rewards

@if (!count($prompt->rewards)) diff --git a/resources/views/widgets/_add_attachments.blade.php b/resources/views/widgets/_add_attachments.blade.php new file mode 100644 index 0000000000..2ec2299ceb --- /dev/null +++ b/resources/views/widgets/_add_attachments.blade.php @@ -0,0 +1,253 @@ +@php + $attachments = hasAttachments($object) ? getAttachments($object) : null; + + $attachmentTypes = getAttachmentTypes(); + $attachmentData = getAttachmentData(); +@endphp + +
+
+

Attachments

+ +

+ You can add attachments to this object by clicking "Add Attachment" and entering the attachment model and type. +
Each attachment can include additional data fields (e.g., description), and you can add custom fields as needed. +

+ {!! isset($info) ? '

' . $info . '

' : '' !!} + + {!! Form::open(['url' => 'admin/attachments']) !!} + {!! Form::hidden('parent_model', get_class($object)) !!} + {!! Form::hidden('parent_id', $object->id) !!} + +
+
Add Attachment
+
+ +
+ +
+ @if ($attachments) +
Attachments for {!! $attachments->first()->parent->displayName ?? $object->displayName !!}
+ @foreach ($attachments as $index => $attachment) +
+
+
+ {!! Form::select('attachment_type[]', $attachmentTypes, $attachment->attachment_type, [ + 'class' => 'form-control attachment-type', + 'placeholder' => 'Select Attachment Type', + ]) !!} +
+
+ {!! Form::select('attachment_id[]', $attachmentData[$attachment->attachment_type], $attachment->attachment_id, [ + 'class' => 'form-control attachment-selectize', + 'placeholder' => 'Select Attachment', + ]) !!} +
+
+
Remove
+
+
+ +
+
+
Data Fields
+
Add Field
+
+
+
+ {!! Form::label("data[$index][description]", 'Description') !!} + {!! Form::text("data[$index][description]", $attachment->description, ['class' => 'form-control', 'placeholder' => 'Optional Description']) !!} +
+
+ + @foreach ($attachment->data ?? [] as $key => $value) + @if ($key == 'description' || $key == 'parsed_description') + @continue + @endif +
+
+ {{ ucfirst($key) }} +
+
+ {!! Form::text("data[$index][$key]", $value, ['class' => 'form-control', 'placeholder' => ucfirst($key)]) !!} +
+
+
X
+
+
+ @endforeach +
+
+ @endforeach + @endif +
+ + @if ($attachments) + + @endif + {!! Form::submit(($attachments ? 'Edit' : 'Create') . ' Attachments', ['class' => 'btn btn-primary float-right']) !!} + + {!! Form::close() !!} +
+
+ +
+
+ @foreach ($attachmentTypes as $attachmentKey => $attachmentType) + {!! Form::select('attachment_id[]', $attachmentData[$attachmentKey], null, ['class' => 'form-control ' . strtolower($attachmentKey) . '-select', 'placeholder' => 'Select ' . $attachmentType]) !!} + @endforeach +
+ +
+
+
+
+
+ {!! Form::select('attachment_type[]', $attachmentTypes, null, [ + 'class' => 'form-control attachment-type', + 'placeholder' => 'Select Attachment Type', + ]) !!} +
+
+
+
Remove
+
+
+ +
+
+
Data Fields
+
Add Field
+
+
+
+ {!! Form::label('data[__INDEX__][description]', 'Description') !!} + {!! Form::text('data[__INDEX__][description]', null, [ + 'class' => 'form-control data-field attachment-text', + 'data-name-template' => 'data[__INDEX__][description]', + 'placeholder' => 'Optional Description', + ]) !!} +
+
+
+
+
+
+ +
+
+
+
+ {!! Form::text('data[__INDEX__][key]', null, ['class' => 'form-control data-key', 'placeholder' => 'Field name (e.g. notes)', 'data-name-template' => 'data[__INDEX__][key]']) !!} +
+
+ {!! Form::text('data[__INDEX__][value]', null, ['class' => 'form-control data-value', 'placeholder' => 'Field value', 'data-name-template' => 'data[__INDEX__][value]']) !!} +
+
+
X
+
+
+
+
+
+ + diff --git a/resources/views/widgets/_attachments.blade.php b/resources/views/widgets/_attachments.blade.php new file mode 100644 index 0000000000..b75b5ac88f --- /dev/null +++ b/resources/views/widgets/_attachments.blade.php @@ -0,0 +1,15 @@ +@php + $attachments = getAttachments($object); + if (!isset($style)) { + $style = null; + } +@endphp +
+ @foreach ($attachments as $attachment) + @if ($style && View::exists('widgets.attachments._' . $style)) + @include('widgets.attachments._' . $style, ['attachment' => $attachment]) + @else + @include('widgets.attachments._card', ['attachment' => $attachment]) + @endif + @endforeach +
diff --git a/resources/views/widgets/attachments/README.md b/resources/views/widgets/attachments/README.md new file mode 100644 index 0000000000..7e952e6f38 --- /dev/null +++ b/resources/views/widgets/attachments/README.md @@ -0,0 +1,11 @@ +# Attachment Widget Styling + +You can pass a custom style to the attachment widget by adding a `style` parameter when including the widget in your view. For example: + +```blade +@include('widgets.attachments', ['object' => $object, 'style' => 'custom-style']) +``` + +The `style` parameter will then be used to include the corresponding sub-view in the widgets/attachments/_custom-style.blade.php file. Make sure to create a sub-view file that matches the style name you provide. + +By default, if no style is provided, the widget will use the `_card` style. \ No newline at end of file diff --git a/resources/views/widgets/attachments/_card.blade.php b/resources/views/widgets/attachments/_card.blade.php new file mode 100644 index 0000000000..a9a03adc10 --- /dev/null +++ b/resources/views/widgets/attachments/_card.blade.php @@ -0,0 +1,14 @@ +
+
+
+ @if ($attachment->attachment->imageUrl) +
+ Attachment Image +
+ @endif +
{!! $attachment->attachment->displayName ?? $attachment->attachment->name !!}
+
{!! $attachment->parsed_description !!}
+ {{-- display custom things here --}} +
+
+
diff --git a/resources/views/world/_item_entry.blade.php b/resources/views/world/_item_entry.blade.php index 25c2b3b355..ff12135ddf 100644 --- a/resources/views/world/_item_entry.blade.php +++ b/resources/views/world/_item_entry.blade.php @@ -140,6 +140,13 @@ @endif
@endif + @if (hasAttachments($item)) +
+
Attachments
+ @include('widgets._attachments', [ + 'object' => $item, + ]) + @endif diff --git a/routes/lorekeeper/admin.php b/routes/lorekeeper/admin.php index 614a265cfb..5901b21146 100644 --- a/routes/lorekeeper/admin.php +++ b/routes/lorekeeper/admin.php @@ -503,3 +503,8 @@ Route::group(['prefix' => 'rewards', 'middleware' => 'power:manage_data'], function () { Route::post('/', 'RewardController@postPopulateRewards'); }); + +// ATTACHMENTS +Route::group(['prefix' => 'attachments', 'middleware' => 'power:manage_data'], function () { + Route::post('/', 'AttachmentController@postCreateEditAttachments'); +});