diff --git a/app/Console/Commands/ConvertExtendedMentions.php b/app/Console/Commands/ConvertExtendedMentions.php new file mode 100644 index 0000000000..5ff01af82d --- /dev/null +++ b/app/Console/Commands/ConvertExtendedMentions.php @@ -0,0 +1,112 @@ +info('Converting extended mentions...'); + $this->info('Converting News...'); + foreach (News::all() as $news) { + $newText = $this->convertText($news->text); + $news->text = $newText; + $news->parsed_text = parse($newText); + $news->save(); + } + + $this->info('Converting Site Pages...'); + foreach (SitePage::all() as $page) { + $newText = $this->convertText($page->text); + $page->text = $newText; + $page->parsed_text = parse($newText); + $page->save(); + } + + $this->info('Converting Sales...'); + foreach (Sales::all() as $sale) { + $newText = $this->convertText($sale->description); + $sale->description = $newText; + $sale->parsed_description = parse($newText); + $sale->save(); + } + + $this->info('Converting Character Profiles...'); + foreach (CharacterProfile::all() as $profile) { + $newText = $this->convertText($profile->description); + $profile->description = $newText; + $profile->parsed_description = parse($newText); + $profile->save(); + } + + $this->info('Converting User Profiles...'); + foreach (UserProfile::all() as $profile) { + $newText = $this->convertText($profile->bio); + $profile->bio = $newText; + $profile->parsed_bio = parse($newText); + $profile->save(); + } + + $this->info('Converting Gallery Submissions...'); + foreach (GallerySubmission::all() as $submission) { + $newText = $this->convertText($submission->description); + $submission->description = $newText; + $submission->parsed_description = parse($newText); + $submission->save(); + } + + $this->info('Conversion complete.'); + } + + private function convertText($text) { + $toMatch = [ + '/\B@([A-Za-z0-9_-]+)/', // matches @mentions + '/\B%([A-Za-z0-9_-]+)/', // matches %mentions + '/\[user=([^\[\]&<>?"\']+)\]/', // matches [user=username] + '/\[userav=([^\[\]&<>?"\']+)\]/', // matches [userav=username] + '/\[character=([^\[\]&<>?"\']+)\]/', // matches [character=name] + '/\[charthumb=([^\[\]&<>?"\']+)\]/', // matches [charthumb=name] + '/\[thumb=([^\[\]&<>?"\']+)\]/', // matches [thumb=name] + ]; + + $replacements = [ + '@$1', + '@$1', + '@$1', + '', + '@$1', + '', + '', + ]; + + foreach ($toMatch as $index => $pattern) { + $text = preg_replace($pattern, $replacements[$index], $text); + } + + return $text; + } +} diff --git a/app/Helpers/AssetHelpers.php b/app/Helpers/AssetHelpers.php index 0743861e1a..594f892fc9 100644 --- a/app/Helpers/AssetHelpers.php +++ b/app/Helpers/AssetHelpers.php @@ -121,6 +121,14 @@ function getAssetModelString($type, $namespaced = true) { } break; + case 'users': case 'user': // users aren't really assets, but it's convenient to be able to get the user model + if ($namespaced) { + return '\App\Models\User\User'; + } else { + return 'User'; + } + break; + case 'user_items': if ($namespaced) { return '\App\Models\User\UserItem'; @@ -129,7 +137,7 @@ function getAssetModelString($type, $namespaced = true) { } break; - case 'characters': + case 'characters': case 'character': if ($namespaced) { return '\App\Models\Character\Character'; } else { @@ -144,6 +152,22 @@ function getAssetModelString($type, $namespaced = true) { return 'CharacterItem'; } break; + + case 'emotes': case 'emote': + if ($namespaced) { + return '\App\Models\Emote'; + } else { + return 'Emote'; + } + break; + + case 'gallery_submissions': case 'gallery_submission': + if ($namespaced) { + return '\App\Models\Gallery\GallerySubmission'; + } else { + return 'GallerySubmission'; + } + break; } return null; diff --git a/app/Helpers/Helpers.php b/app/Helpers/Helpers.php index f0ac04e6ca..180beec0e4 100644 --- a/app/Helpers/Helpers.php +++ b/app/Helpers/Helpers.php @@ -98,240 +98,6 @@ function format_masterlist_number($number, $digits) { return sprintf('%0'.$digits.'d', $number); } -/** - * Parses a piece of user-entered text for HTML output and optionally gets pings. - * - * @param string $text - * @param array $pings - * - * @return string - */ -function parse($text, &$pings = null) { - if (!$text) { - return null; - } - - require_once base_path().'/vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php'; - - $config = HTMLPurifier_Config::createDefault(); - $config->set('Attr.EnableID', true); - $config->set('HTML.DefinitionID', 'include'); - $config->set('HTML.DefinitionRev', 2); - if ($def = $config->maybeGetRawHTMLDefinition()) { - $def->addElement('include', 'Block', 'Empty', 'Common', ['file*' => 'URI', 'height' => 'Text', 'width' => 'Text']); - $def->addAttribute('a', 'data-toggle', 'Enum#collapse,tab'); - $def->addAttribute('a', 'aria-expanded', 'Enum#true,false'); - $def->addAttribute('a', 'data-target', 'Text'); - $def->addAttribute('div', 'data-parent', 'Text'); - } - - $purifier = new HTMLPurifier($config); - $text = $purifier->purify($text); - - $users = $characters = null; - $text = parseUsers($text, $users); - $text = parseUsersAndAvatars($text, $users); - $text = parseUserIDs($text, $users); - $text = parseUserIDsForAvatars($text, $users); - $text = parseCharacters($text, $characters); - $text = parseCharacterThumbs($text, $characters); - $text = parseGalleryThumbs($text, $submissions); - if ($pings) { - $pings = ['users' => $users, 'characters' => $characters]; - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match user mentions - * and replace with a link. - * - * @param string $text - * @param mixed $users - * - * @return string - */ -function parseUsers($text, &$users) { - $matches = null; - $users = []; - $count = preg_match_all('/\B@([A-Za-z0-9_-]+)/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $user = App\Models\User\User::where('name', $match)->first(); - if ($user) { - $users[] = $user; - $text = preg_replace('/\B@'.$match.'/', $user->displayName, $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match user mentions - * and replace with a link and avatar. - * - * @param string $text - * @param mixed $users - * - * @return string - */ -function parseUsersAndAvatars($text, &$users) { - $matches = null; - $users = []; - $count = preg_match_all('/\B%([A-Za-z0-9_-]+)/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $user = App\Models\User\User::where('name', $match)->first(); - if ($user) { - $users[] = $user; - $text = preg_replace('/\B%'.$match.'/', ''.$user->name.'\'s Avatar'.$user->displayName, $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match userid mentions - * and replace with a link. - * - * @param string $text - * @param mixed $users - * - * @return string - */ -function parseUserIDs($text, &$users) { - $matches = null; - $users = []; - $count = preg_match_all('/\[user=([^\[\]&<>?"\']+)\]/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $user = App\Models\User\User::where('id', $match)->first(); - if ($user) { - $users[] = $user; - $text = preg_replace('/\[user='.$match.'\]/', $user->displayName, $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match userid mentions - * and replace with a user avatar. - * - * @param string $text - * @param mixed $users - * - * @return string - */ -function parseUserIDsForAvatars($text, &$users) { - $matches = null; - $users = []; - $count = preg_match_all('/\[userav=([^\[\]&<>?"\']+)\]/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $user = App\Models\User\User::where('id', $match)->first(); - if ($user) { - $users[] = $user; - $text = preg_replace('/\[userav='.$match.'\]/', ''.$user->name.'\'s Avatar', $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match character mentions - * and replace with a link. - * - * @param string $text - * @param mixed $characters - * - * @return string - */ -function parseCharacters($text, &$characters) { - $matches = null; - $characters = []; - $count = preg_match_all('/\[character=([^\[\]&<>?"\']+)\]/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $character = App\Models\Character\Character::where('slug', $match)->first(); - if ($character) { - $characters[] = $character; - $text = preg_replace('/\[character='.$match.'\]/', $character->displayName, $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match character mentions - * and replace with a thumbnail. - * - * @param string $text - * @param mixed $characters - * - * @return string - */ -function parseCharacterThumbs($text, &$characters) { - $matches = null; - $characters = []; - $count = preg_match_all('/\[charthumb=([^\[\]&<>?"\']+)\]/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $character = App\Models\Character\Character::where('slug', $match)->first(); - if ($character) { - $characters[] = $character; - $text = preg_replace('/\[charthumb='.$match.'\]/', 'Thumbnail of '.$character->fullName.'', $text); - } - } - } - - return $text; -} - -/** - * Parses a piece of user-entered text to match gallery submission thumb mentions - * and replace with a link. - * - * @param string $text - * @param mixed $submissions - * - * @return string - */ -function parseGalleryThumbs($text, &$submissions) { - $matches = null; - $submissions = []; - $count = preg_match_all('/\[thumb=([^\[\]&<>?"\']+)\]/', $text, $matches); - if ($count) { - $matches = array_unique($matches[1]); - foreach ($matches as $match) { - $submission = App\Models\Gallery\GallerySubmission::where('id', $match)->first(); - if ($submission) { - $submissions[] = $submission; - $text = preg_replace('/\[thumb='.$match.'\]/', ''.view('widgets._gallery_thumb', ['submission' => $submission]).'', $text); - } - } - } - - return $text; -} - /** * Generates a string of random characters of the specified length. * @@ -483,3 +249,122 @@ function faVersion() { return asset($directory.'/'.$version.'.min.css'); } + +/**************************************************************************************** + * + * PARSING FUNCTIONS + * + ****************************************************************************************/ + +/** + * Parses a piece of user-entered text for HTML output and optionally gets pings. + * + * @param string $text + * @param array $pings + * + * @return string + */ +function parse($text, &$pings = null) { + if (!$text) { + return null; + } + + require_once base_path().'/vendor/ezyang/htmlpurifier/library/HTMLPurifier.auto.php'; + + $config = HTMLPurifier_Config::createDefault(); + $config->set('Attr.EnableID', true); + $config->set('HTML.DefinitionID', 'include'); + $config->set('HTML.DefinitionRev', 2); + if ($def = $config->maybeGetRawHTMLDefinition()) { + $def->addElement('include', 'Block', 'Empty', 'Common', ['file*' => 'URI', 'height' => 'Text', 'width' => 'Text']); + $def->addAttribute('a', 'data-toggle', 'Enum#collapse,tab'); + $def->addAttribute('a', 'aria-expanded', 'Enum#true,false'); + $def->addAttribute('a', 'data-target', 'Text'); + $def->addAttribute('div', 'data-parent', 'Text'); + + // mentions + $elements = ['a', 'div', 'span', 'p', 'img']; + foreach ($elements as $element) { + $def->addAttribute($element, 'data-mention-type', 'Text'); + $def->addAttribute($element, 'data-id', 'Number'); + } + } + + $text = parseMentions($text, $pings); + + $purifier = new HTMLPurifier($config); + $text = $purifier->purify($text); + + return $text; +} + +/** + * Parses a piece of user-entered text to match mentions, + * We don't replace the text and instead modify it on display to allow for name, image, hash, etc. changes, without breaking links or mentions. + * + * @param string $text + * @param array $pings + * + * @return string + */ +function parseMentions($text, &$pings) { + $matches = []; + $count = preg_match_all( + '/<([^ >]+)[^>]*data-mention-type="([^"]+)"[^>]*data-id="([^"]+)"[^>]*>(.*?)<\/\1>/s', + $text, + $matches, + PREG_SET_ORDER + ); + + foreach ($matches as $match) { + $parentElement = $match[0]; + $type = $match[2]; + $id = $match[3]; + + $model = getAssetModelString($type); + $object = $model::find($id); + + if (!$object) { + continue; + } + + $pings[$type][] = $object; + $hasImage = preg_match('/]+>/i', $parentElement); + $text = str_replace($parentElement, $hasImage ? $object->mentionImage : $object->mentionDisplayName, $text); + } + + return $text; +} + +/** + * Sends a notification to users or character's owners. + * + * @param mixed $pings + * @param mixed $user + * @param mixed $mention + */ +function sendNotifications($pings, $user, $mention) { + if ($pings) { + foreach ($pings as $type => $objects) { + foreach ($objects as $object) { + if ($type == 'user' && $object->id != $user->id) { + App\Facades\Notifications::create('MENTIONED', $object, [ + 'sender_url' => $user->url, + 'sender_name' => $user->name, + 'mention_target' => 'you', + 'mention_url' => $mention->url, + 'mention_type' => $mention->mentionType, + ]); + } elseif ($type == 'character' && $object->user->id != $user->id) { + App\Facades\Notifications::create('MENTIONED', $object->user, [ + 'sender_url' => $user->url, + 'sender_name' => $user->name, + 'mention_target' => 'your character '.$object->displayName, + 'mention_url' => $mention->url, + 'mention_type' => $mention->mentionType, + ]); + } + } + } + } +} diff --git a/app/Http/Controllers/Admin/EmoteController.php b/app/Http/Controllers/Admin/EmoteController.php new file mode 100644 index 0000000000..0a1b9d1037 --- /dev/null +++ b/app/Http/Controllers/Admin/EmoteController.php @@ -0,0 +1,102 @@ + Emote::all(), + ]); + } + + /** + * Shows the create emote page. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getCreateEmote() { + return view('admin.emotes.create_edit_emote', [ + 'emote' => new Emote, + ]); + } + + /** + * Shows the edit emote page. + * + * @param mixed $id + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getEditEmote($id) { + return view('admin.emotes.create_edit_emote', [ + 'emote' => Emote::findOrFail($id), + ]); + } + + /** + * Creates or edits an emote. + * + * @param App\Services\EmoteService $service + * @param int|null $id + * + * @return \Illuminate\Http\RedirectResponse + */ + public function postCreateEditEmote(Request $request, EmoteService $service, $id = null) { + $id ? $request->validate(Emote::$updateRules) : $request->validate(Emote::$createRules); + $data = $request->only([ + 'name', 'image', 'description', 'alt_text', 'is_active', + ]); + if ($id && $service->updateEmote(Emote::find($id), $data, Auth::user())) { + flash('Emote updated successfully.')->success(); + } elseif (!$id && $emote = $service->createEmote($data, Auth::user())) { + flash('Emote created successfully.')->success(); + + return redirect()->to('admin/emotes/edit/'.$emote->id); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } + + /** + * Deletes a emote. + * + * @param mixed $id + */ + public function getDeleteEmote($id) { + $emote = Emote::findOrFail($id); + + return view('admin.emotes._delete_emote', [ + 'emote' => $emote, + ]); + } + + /** + * Deletes a emote. + * + * @param mixed $id + */ + public function postDeleteEmote(Request $request, EmoteService $service, $id) { + if ($service->deleteEmote(Emote::find($id))) { + flash('Emote deleted successfully.')->success(); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->to('admin/emotes'); + } +} diff --git a/app/Http/Controllers/BrowseController.php b/app/Http/Controllers/BrowseController.php index cfc44cf5a6..e2c8132357 100644 --- a/app/Http/Controllers/BrowseController.php +++ b/app/Http/Controllers/BrowseController.php @@ -7,7 +7,11 @@ use App\Models\Character\CharacterCategory; use App\Models\Character\CharacterImage; use App\Models\Character\Sublist; +use App\Models\Emote; use App\Models\Feature\Feature; +use App\Models\Gallery\GallerySubmission; +use App\Models\Item\Item; +use App\Models\Prompt\Prompt; use App\Models\Rank\Rank; use App\Models\Rarity; use App\Models\Species\Species; @@ -267,6 +271,130 @@ public function getSublist(Request $request, $key) { ]); } + /************************************************************************************************** + * + * MENTIONS + * + **************************************************************************************************/ + + /** + * Shows the list (users, emotes, etc) matching the search query. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getSearchMentions(Request $request) { + $delimiter = $request->get('delimiter'); + $queryString = $request->get('query'); + $result = collect(); + switch ($delimiter) { + case '@': + $users = User::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($user) { + return [ + 'type' => '@', + 'name' => $user->name, + 'image' => $user->avatarUrl, + 'mention_display_name' => $user->mentionDisplayName, + ]; + }); + + $characters = Character::visible()->where(function ($query) use ($queryString) { + $query->where('name', 'LIKE', "%{$queryString}%")->orWhere('slug', 'LIKE', "%{$queryString}%"); + })->orderBy('name')->get()->map(function ($character) { + return [ + 'type' => '@', + 'name' => $character->fullName, + 'image' => $character->image->thumbnailUrl, + 'mention_display_name' => $character->mentionDisplayName, + ]; + }); + + $result = collect($users)->merge(collect($characters)); + break; + case ':': + $emotes = Emote::where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($emote) { + return [ + 'type' => ':', + 'name' => $emote->name.' (Emote)', + 'image' => $emote->imageUrl, + 'mention_display_name' => $emote->mentionImage, + ]; + }); + + $items = Item::released()->where('has_image', 1)->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($item) { + return [ + 'type' => ':', + 'name' => $item->name.' (Item)', + 'image' => $item->imageUrl, + 'mention_display_name' => $item->mentionImage, + ]; + }); + + $result = collect($emotes)->merge(collect($items)); + break; + case '%': + $users = User::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($user) { + return [ + 'type' => '%', + 'name' => $user->name.' (User)', + 'image' => $user->avatarUrl, + 'mention_display_name' => $user->mentionImage, + ]; + }); + + $characters = Character::visible()->where(function ($query) use ($queryString) { + $query->where('name', 'LIKE', "%{$queryString}%")->orWhere('slug', 'LIKE', "%{$queryString}%"); + })->orderBy('name')->get()->map(function ($character) { + return [ + 'type' => '%', + 'name' => $character->fullName.' (Character)', + 'image' => $character->image->thumbnailUrl, + 'mention_display_name' => $character->mentionImage, + ]; + }); + + $items = Item::released()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($item) { + return [ + 'type' => '%', + 'name' => $item->name.' (Item)', + 'image' => $item->has_image ? $item->imageUrl : null, + 'mention_display_name' => $item->has_image ? $item->mentionImage : $item->mentionDisplayName, + ]; + }); + + $prompts = Prompt::active()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($prompt) { + return [ + 'type' => '%', + 'name' => $prompt->name.' (Prompt)', + 'image' => $prompt->has_image ? $prompt->imageUrl : null, + 'mention_display_name' => $prompt->has_image ? $prompt->mentionImage : $prompt->mentionDisplayName, + ]; + }); + + $traits = Feature::visible()->where('name', 'LIKE', "%{$queryString}%")->orderBy('name')->get()->map(function ($trait) { + return [ + 'type' => '%', + 'name' => $trait->name.' (Trait)', + 'image' => $trait->has_image ? $trait->mentionImage : $trait->mentionDisplayName, + 'mention_display_name' => $trait->mentionDisplayName, + ]; + }); + + $gallerySubmissions = GallerySubmission::visible()->where('title', 'LIKE', "%{$queryString}%")->orderBy('title')->get()->map(function ($submission) { + return [ + 'type' => '%', + 'name' => $submission->title.' (Gallery Submission)', + 'image' => $submission->has_image ? $submission->imageUrl : null, + 'mention_display_name' => $submission->has_image ? $submission->mentionImage : $submission->mentionDisplayName, + ]; + }); + + $result = collect($users)->merge(collect($characters))->merge(collect($items))->merge(collect($prompts))->merge(collect($traits))->merge(collect($gallerySubmissions)); + break; + } + + return response()->json($result); + } + /** * Handles character search/filtering. * diff --git a/app/Http/Controllers/Comments/CommentController.php b/app/Http/Controllers/Comments/CommentController.php index 1adb184dd2..52e37b9809 100644 --- a/app/Http/Controllers/Comments/CommentController.php +++ b/app/Http/Controllers/Comments/CommentController.php @@ -82,7 +82,8 @@ public function store(Request $request, $model, $id) { $comment->commentable()->associate($base); - $comment->comment = config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message; + $pings = []; + $comment->comment = parse($request->message, $pings); $comment->approved = !config('comments.approval_required'); $comment->type = isset($request['type']) && $request['type'] ? $request['type'] : 'User-User'; @@ -154,6 +155,10 @@ public function store(Request $request, $model, $id) { ]); } + if ($pings) { + sendNotifications($pings, Auth::user(), $comment); + } + return Redirect::to(URL::previous().'#comment-'.$comment->getKey()); } @@ -173,13 +178,13 @@ public function update(Request $request, Comment $comment) { 'comment_id' => $comment->id, 'data' => [ 'action' => 'edit', - 'old_comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($comment->comment) : $comment->comment, - 'new_comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message, + 'old_comment' => parse($comment->comment), + 'new_comment' => parse($request->message), ], ]); $comment->update([ - 'comment' => config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message, + 'comment' => parse($request->message), ]); return Redirect::to(URL::previous().'#comment-'.$comment->getKey()); @@ -215,7 +220,7 @@ public function reply(Request $request, Comment $comment) { $reply->commenter()->associate(Auth::user()); $reply->commentable()->associate($comment->commentable); $reply->parent()->associate($comment); - $reply->comment = config('lorekeeper.settings.wysiwyg_comments') ? parse($request->message) : $request->message; + $reply->comment = parse($request->message); $reply->type = $comment->type; $reply->approved = !config('comments.approval_required'); $reply->save(); diff --git a/app/Http/Controllers/WorldController.php b/app/Http/Controllers/WorldController.php index e2488573da..d77cca8244 100644 --- a/app/Http/Controllers/WorldController.php +++ b/app/Http/Controllers/WorldController.php @@ -5,6 +5,7 @@ use App\Models\Character\CharacterCategory; use App\Models\Currency\Currency; use App\Models\Currency\CurrencyCategory; +use App\Models\Emote; use App\Models\Feature\Feature; use App\Models\Feature\FeatureCategory; use App\Models\Item\Item; @@ -595,4 +596,21 @@ public function getCharacterCategories(Request $request) { 'categories' => $query->visible(Auth::user() ?? null)->orderBy('sort', 'DESC')->orderBy('id')->paginate(20)->appends($request->query()), ]); } + + /** + * Shows the emotes page. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getEmotes(Request $request) { + $query = Emote::active(); + $data = $request->only(['name']); + if (isset($data['name'])) { + $query->where('name', 'LIKE', '%'.$data['name'].'%'); + } + + return view('world.emotes', [ + 'emotes' => $query->orderBy('name', 'DESC')->paginate(20)->appends($request->query()), + ]); + } } diff --git a/app/Http/Middleware/ParsePostRequestFields.php b/app/Http/Middleware/ParsePostRequestFields.php index f108b5c7fe..ebd103eed3 100644 --- a/app/Http/Middleware/ParsePostRequestFields.php +++ b/app/Http/Middleware/ParsePostRequestFields.php @@ -15,7 +15,7 @@ class ParsePostRequestFields { */ public function handle(Request $request, Closure $next) { if ($request->isMethod('post')) { - $excludedFields = ['_token', 'password', 'email', 'description', 'text', 'evaluation', 'criteria']; + $excludedFields = ['_token', 'password', 'email', 'description', 'text', 'evaluation', 'criteria', 'message']; $strippedFields = ['name', 'title']; $parsedFields = []; diff --git a/app/Models/Character/Character.php b/app/Models/Character/Character.php index b0f3301989..ba41dafda3 100644 --- a/app/Models/Character/Character.php +++ b/app/Models/Character/Character.php @@ -315,6 +315,24 @@ public function getDisplayNameAttribute() { return ''.$this->fullName.''; } + /** + * Displays the character's name, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionDisplayNameAttribute() { + return '@'.$this->fullName.''; + } + + /** + * Displays the character's thumbnail image, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->name.''; + } + /** * Gets the character's name, including their code and user-assigned name. * If this is a MYO slot, simply returns the slot's name. diff --git a/app/Models/Comment/Comment.php b/app/Models/Comment/Comment.php index cb9681b0f1..0792e6ec50 100644 --- a/app/Models/Comment/Comment.php +++ b/app/Models/Comment/Comment.php @@ -151,30 +151,25 @@ public function getEndOfThreadAttribute() { * @return string */ public function getCommentAttribute() { - if (config('lorekeeper.settings.wysiwyg_comments')) { - return preg_replace_callback( - '/(?'.$domain.''; - }, - $this->attributes['comment'] - ); - } - return preg_replace_callback( - '/(?'.$domain.''; }, $this->attributes['comment'] ); } + + /** + * Returns the mention type text for ping notifications. + * + * @return string + */ + public function getMentionTypeAttribute() { + return 'in a comment'; + } } diff --git a/app/Models/Emote.php b/app/Models/Emote.php new file mode 100644 index 0000000000..ef88cc1f07 --- /dev/null +++ b/app/Models/Emote.php @@ -0,0 +1,142 @@ + 'required|unique:items|between:3,100', + 'image' => 'required|mimes:png,jpg,jpeg,gif,apng,webp', + ]; + + /** + * Validation rules for updating. + * + * @var array + */ + public static $updateRules = [ + 'name' => 'required|between:3,100', + 'image' => 'mimes:png,jpg,jpeg,gif,apng,webp', + ]; + + /********************************************************************************************** + + SCOPES + + **********************************************************************************************/ + + /** + * Scope a query to retrieve only active emotes. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) { + return $query->where('is_active', 1); + } + + /********************************************************************************************** + + ACCESSORS + + **********************************************************************************************/ + + /** + * Displays the emote in the tinymce editor. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->alt_text.''; + } + + /** + * Gets the file directory containing the model's image. + * + * @return string + */ + public function getImageDirectoryAttribute() { + return 'images/data/emotes'; + } + + /** + * Gets the file name of the model's image. + * + * @return string + */ + public function getImageFileNameAttribute() { + return $this->id.'-image.png'; + } + + /** + * Gets the path to the file directory containing the model's image. + * + * @return string + */ + public function getImagePathAttribute() { + return public_path($this->imageDirectory); + } + + /** + * Gets the URL of the model's image. + * + * @return string + */ + public function getImageUrlAttribute() { + return asset($this->imageDirectory.'/'.$this->imageFileName); + } + + /** + * Gets the admin edit URL. + * + * @return string + */ + public function getAdminUrlAttribute() { + return url('admin/emotes/edit/'.$this->id); + } + + /** + * Gets the power required to edit this model. + * + * @return string + */ + public function getAdminPowerAttribute() { + return 'edit_data'; + } + + /********************************************************************************************** + + OTHER FUNCTIONS + + **********************************************************************************************/ + + /** + * Returns the emote's image as an HTML image element with alt text. + * + * @return string + */ + public function getImage() { + return ''.$this->alt_text.''; + } +} diff --git a/app/Models/Feature/Feature.php b/app/Models/Feature/Feature.php index a09783407b..ba15b91529 100644 --- a/app/Models/Feature/Feature.php +++ b/app/Models/Feature/Feature.php @@ -204,6 +204,22 @@ public function getDisplayNameAttribute() { return ''.$this->name.''.($this->rarity ? ' ('.$this->rarity->displayName.')' : ''); } + /** + * Displays the feature's name, but with specific classes to identify mentions. + */ + public function getMentionDisplayNameAttribute() { + return ''.$this->name.''.($this->rarity ? ' ('.$this->rarity->displayName.')' : ''); + } + + /** + * Displays the feature's image, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->name.''; + } + /** * Gets the file directory containing the model's image. * diff --git a/app/Models/Gallery/GallerySubmission.php b/app/Models/Gallery/GallerySubmission.php index 032732e867..cbb6c4522c 100644 --- a/app/Models/Gallery/GallerySubmission.php +++ b/app/Models/Gallery/GallerySubmission.php @@ -366,6 +366,22 @@ public function getDisplayNameAttribute() { return ''.$this->displayTitle.''; } + /** + * Displays the submission's name, but with specific classes to identify mentions. + */ + public function getMentionDisplayNameAttribute() { + return ''.$this->name.''; + } + + /** + * Displays the submission's image, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->name.''; + } + /** * Get the viewing URL of the submission. * diff --git a/app/Models/Item/Item.php b/app/Models/Item/Item.php index a63c4a2855..0a65e2b766 100644 --- a/app/Models/Item/Item.php +++ b/app/Models/Item/Item.php @@ -197,6 +197,22 @@ public function getDisplayNameAttribute() { return ''.$this->name.''; } + /** + * Displays the item's name, but with specific classes to identify mentions. + */ + public function getMentionDisplayNameAttribute() { + return ''.$this->name.''; + } + + /** + * Displays the item in the tinymce editor. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->alt_text.''; + } + /** * Gets the file directory containing the model's image. * diff --git a/app/Models/News.php b/app/Models/News.php index eaef37b2a4..7c6cf25b0c 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -203,6 +203,15 @@ public function getAdminPowerAttribute() { return 'manage_news'; } + /** + * Returns the mention type text for ping notifications. + * + * @return string + */ + public function getMentionTypeAttribute() { + return 'in a news post'; + } + /********************************************************************************************** OTHER FUNCTIONS diff --git a/app/Models/Notification.php b/app/Models/Notification.php index ff439e6ccb..8e25cc777e 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -64,10 +64,10 @@ public function user() { public function getMessageAttribute() { $notification = config('lorekeeper.notifications.'.$this->notification_type_id); - $message = $notification['message']; + $message = $notification ? $notification['message'] : 'Unknown notification.'; // Replace the URL... - $message = str_replace('{url}', url($notification['url']), $message); + $message = str_replace('{url}', url($notification ? $notification['url'] : '/'), $message); // Replace any variables in data... $data = $this->data; @@ -90,6 +90,7 @@ public function getMessageAttribute() { public static function getNotificationId($type) { return constant('self::'.$type); } + /********************************************************************************************** CONSTANTS @@ -144,6 +145,7 @@ public static function getNotificationId($type) { public const REPORT_CLOSED = 221; public const COMMENT_MADE = 239; public const COMMENT_REPLY = 240; + public const MENTIONED = 299; public const CHARACTER_ITEM_GRANT = 501; public const CHARACTER_ITEM_REMOVAL = 502; public const GALLERY_SUBMISSION_COLLABORATOR = 505; diff --git a/app/Models/Prompt/Prompt.php b/app/Models/Prompt/Prompt.php index b0d52d8df2..7aa5e954c2 100644 --- a/app/Models/Prompt/Prompt.php +++ b/app/Models/Prompt/Prompt.php @@ -230,6 +230,22 @@ public function getDisplayNameAttribute() { return ''.$this->name.''; } + /** + * Displays the prompts's name, but with specific classes to identify mentions. + */ + public function getMentionDisplayNameAttribute() { + return ''.$this->name.''; + } + + /** + * Displays the prompt's image, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionImageAttribute() { + return ''.$this->name.''; + } + /** * Gets the file directory containing the model's image. * diff --git a/app/Models/Sales/Sales.php b/app/Models/Sales/Sales.php index 404b13751a..4570f488e4 100644 --- a/app/Models/Sales/Sales.php +++ b/app/Models/Sales/Sales.php @@ -249,6 +249,15 @@ public function getAdminPowerAttribute() { return 'manage_sales'; } + /** + * Returns the mention type text for ping notifications. + * + * @return string + */ + public function getMentionTypeAttribute() { + return 'in a sales post'; + } + /********************************************************************************************** OTHER FUNCTIONS diff --git a/app/Models/User/User.php b/app/Models/User/User.php index e89c18cd67..9de7f5cd87 100644 --- a/app/Models/User/User.php +++ b/app/Models/User/User.php @@ -361,6 +361,28 @@ public function getDisplayNameAttribute() { return ($this->is_banned ? '' : '').''.$this->name.''.($this->is_banned ? '' : ''); } + /** + * Displays the user's name, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionDisplayNameAttribute() { + return '' + .($this->is_banned ? '' : '').'@'.$this->name.''.($this->is_banned ? '' : '') + .''; + } + + /** + * Displays the user's avatar image, but with specific classes to identify mentions. + * + * @return string + */ + public function getMentionImageAttribute() { + return '' + .''.$this->name.'' + .''; + } + /** * Gets the user's last username change. * @@ -398,7 +420,7 @@ public function getDisplayAliasAttribute() { return '(Unverified)'; } - return $this->primaryAlias->displayAlias; + return $this->primaryAlias?->displayAlias ?? '(No Alias)'; } /** diff --git a/app/Services/EmoteService.php b/app/Services/EmoteService.php new file mode 100644 index 0000000000..d85f151305 --- /dev/null +++ b/app/Services/EmoteService.php @@ -0,0 +1,120 @@ +handleImage($image, $emote->imagePath, $emote->imageFileName); + } + + return $this->commitReturn($emote); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Updates an emote. + * + * @param Emote $emote + * @param array $data + * @param \App\Models\User\User $user + * + * @return bool|Emote + */ + public function updateEmote($emote, $data, $user) { + DB::beginTransaction(); + + try { + // More specific validation + if (Emote::where('name', $data['name'])->where('id', '!=', $emote->id)->exists()) { + throw new \Exception('The name has already been taken.'); + } + + $image = null; + if (isset($data['image']) && $data['image']) { + $image = $data['image']; + unset($data['image']); + } + $data['is_active'] = isset($data['is_active']); + + $emote->update($data); + + if ($emote) { + $this->handleImage($image, $emote->imagePath, $emote->imageFileName); + } + + return $this->commitReturn($emote); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } + + /** + * Deletes an emote. + * + * @param Emote\Emote $emote + * + * @return bool + */ + public function deleteEmote($emote) { + DB::beginTransaction(); + + try { + if (file_exists($emote->imagePath.'/'.$emote->imageFileName)) { + $this->deleteImage($emote->imagePath, $emote->imageFileName); + } + $emote->delete(); + + return $this->commitReturn(true); + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return $this->rollbackReturn(false); + } +} diff --git a/app/Services/NewsService.php b/app/Services/NewsService.php index d283094a26..b13d7f1936 100644 --- a/app/Services/NewsService.php +++ b/app/Services/NewsService.php @@ -28,7 +28,8 @@ public function createNews($data, $user) { DB::beginTransaction(); try { - $data['parsed_text'] = parse($data['text']); + $pings = []; + $data['parsed_text'] = parse($data['text'], $pings); $data['user_id'] = $user->id; if (!isset($data['is_visible'])) { $data['is_visible'] = 0; @@ -63,6 +64,10 @@ public function createNews($data, $user) { $this->alertUsers(); } + if ($pings) { + sendNotifications($pings, $user, $news); + } + return $this->commitReturn($news); } catch (\Exception $e) { $this->setError('error', $e->getMessage()); diff --git a/app/Services/SalesService.php b/app/Services/SalesService.php index decf127e39..ed6fa9806c 100644 --- a/app/Services/SalesService.php +++ b/app/Services/SalesService.php @@ -31,7 +31,8 @@ public function createSales($data, $user) { DB::beginTransaction(); try { - $data['parsed_text'] = parse($data['text']); + $pings = []; + $data['parsed_text'] = parse($data['text'], $pings); $data['user_id'] = $user->id; if (!isset($data['is_visible'])) { $data['is_visible'] = 0; @@ -85,6 +86,10 @@ public function createSales($data, $user) { $this->alertUsers(); } + if ($pings) { + sendNotifications($pings, $user, $sales); + } + return $this->commitReturn($sales); } catch (\Exception $e) { $this->setError('error', $e->getMessage()); diff --git a/config/lorekeeper/admin_sidebar.php b/config/lorekeeper/admin_sidebar.php index 409c62f00d..119dd016a1 100644 --- a/config/lorekeeper/admin_sidebar.php +++ b/config/lorekeeper/admin_sidebar.php @@ -65,6 +65,10 @@ 'name' => 'Pages', 'url' => 'admin/pages', ], + [ + 'name' => 'Emotes', + 'url' => 'admin/emotes', + ], ], ], 'Users' => [ diff --git a/config/lorekeeper/extension_tracker.php b/config/lorekeeper/extension_tracker.php new file mode 100644 index 0000000000..c831fb8e01 --- /dev/null +++ b/config/lorekeeper/extension_tracker.php @@ -0,0 +1,16 @@ + [ + // 'key' => 'extension_tracker', + // 'wiki_key' => 'Extension_Tracker', + // 'creators' => json_encode([ + // 'Uri' => 'https://github.com/preimpression/', + // ]), + // 'version' => '1.0.0', + // ], + +]; diff --git a/config/lorekeeper/mentions.php b/config/lorekeeper/mentions.php new file mode 100644 index 0000000000..65d6b14a25 --- /dev/null +++ b/config/lorekeeper/mentions.php @@ -0,0 +1,8 @@ + ['@', ':', '%'], + +]; diff --git a/config/lorekeeper/notifications.php b/config/lorekeeper/notifications.php index 834cac3d6f..8fde9a3f98 100644 --- a/config/lorekeeper/notifications.php +++ b/config/lorekeeper/notifications.php @@ -347,6 +347,13 @@ 'url' => '', ], + // MENTIONED + 299 => [ + 'name' => 'Mentioned', + 'message' => '{sender_name} has mentioned {mention_target} {mention_type}. See Context.', + 'url' => '', + ], + // CHARACTER_ITEM_GRANT 501 => [ 'name' => 'Character Item Grant', diff --git a/database/migrations/2023_03_31_142746_add_emotes.php b/database/migrations/2023_03_31_142746_add_emotes.php new file mode 100644 index 0000000000..42e9d3cf43 --- /dev/null +++ b/database/migrations/2023_03_31_142746_add_emotes.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->boolean('is_active')->default(true); + }); + } + + /** + * Reverse the migrations. + */ + public function down() { + Schema::dropIfExists('emotes'); + } +} diff --git a/database/migrations/2025_01_22_113431_add_description_to_emotes.php b/database/migrations/2025_01_22_113431_add_description_to_emotes.php new file mode 100644 index 0000000000..4e0ef29dc1 --- /dev/null +++ b/database/migrations/2025_01_22_113431_add_description_to_emotes.php @@ -0,0 +1,29 @@ +text('description')->nullable()->default(null)->after('name'); + $table->string('alt_text')->nullable()->default(null)->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::table('emotes', function (Blueprint $table) { + // + $table->dropColumn('description'); + $table->dropColumn('alt_text'); + }); + } +}; diff --git a/public/css/lorekeeper.css b/public/css/lorekeeper.css index 0e8e2acf4a..70d7b94047 100644 --- a/public/css/lorekeeper.css +++ b/public/css/lorekeeper.css @@ -774,6 +774,17 @@ tr.accountbound { filter: none; } +/* MENTIONS *************************************************************************************/ +.data-mention { + padding: 0.1em 0.25em; + border-radius: 0.15em; + background-color: #e6e8fd; +} + +.data-mention a { + color: #5d66c9; +} + /* CODE EDITOR *************************************************************************************/ #ace-code-editor-wrapper .editor-btn { background: white; diff --git a/public/css/mentions.css b/public/css/mentions.css new file mode 100644 index 0000000000..d3935c1d37 --- /dev/null +++ b/public/css/mentions.css @@ -0,0 +1,90 @@ +.rte-autocomplete { + position: absolute; + top: 0px; + left: 0px; + display: block; + z-index: 1000; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + list-style: none; + background-color: #fff; + border: 1px solid #ccc; + border: 1px solid rgba(0,0,0,0.2); + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0,0,0,0.2); + -moz-box-shadow: 0 5px 10px rgba(0,0,0,0.2); + box-shadow: 0 5px 10px rgba(0,0,0,0.2); + -webkit-background-clip: padding-box; + -moz-background-clip: padding; + background-clip: padding-box; + font-family: "Helvetica Neue",Helvetica,Arial,sans-serif; + font-size: 14px; +} + +.rte-autocomplete:before { + content: ''; + display: inline-block; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid #ccc; + border-bottom-color: rgba(0, 0, 0, 0.2); + position: absolute; + top: -7px; + left: 9px; +} + +.rte-autocomplete:after { + content: ''; + display: inline-block; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid white; + position: absolute; + top: -6px; + left: 10px; +} + +.rte-autocomplete > li.loading { + background: url(../images/loading.gif) no-repeat center center; + height: 16px; +} + +.rte-autocomplete > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 20px; + color: #1a1919; + white-space: nowrap; + text-decoration: none; +} + +.rte-autocomplete >li > a:hover, .rte-autocomplete > li > a:focus, .rte-autocomplete:hover > a, .rte-autocomplete:focus > a { + color: #fff; + text-decoration: none; + background-color: #0081c2; + background-image: -moz-linear-gradient(top,#08c,#0077b3); + background-image: -webkit-gradient(linear,0 0,0 100%,from(#08c),to(#0077b3)); + background-image: -webkit-linear-gradient(top,#08c,#0077b3); + background-image: -o-linear-gradient(top,#08c,#0077b3); + background-image: linear-gradient(to bottom,#08c,#0077b3); + background-repeat: repeat-x; +} + +.rte-autocomplete >.active > a, .rte-autocomplete > .active > a:hover, .rte-autocomplete > .active > a:focus { + color: #fff; + text-decoration: none; + background-color: #0081c2; + background-image: -moz-linear-gradient(top,#08c,#0077b3); + background-image: -webkit-gradient(linear,0 0,0 100%,from(#08c),to(#0077b3)); + background-image: -webkit-linear-gradient(top,#08c,#0077b3); + background-image: -o-linear-gradient(top,#08c,#0077b3); + background-image: linear-gradient(to bottom,#08c,#0077b3); + background-repeat: repeat-x; + outline: 0; +} diff --git a/public/js/plugins/mention/plugin.min.js b/public/js/plugins/mention/plugin.min.js new file mode 100644 index 0000000000..61d0e884e8 --- /dev/null +++ b/public/js/plugins/mention/plugin.min.js @@ -0,0 +1 @@ +!function(t){"use strict";if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t(require("jquery"));else if("function"==typeof define&&define.amd)define(["jquery"],t);else{t(("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).jQuery)}}(function(t){"use strict";var e=function(e,i){this.editor=e,this.options=t.extend({},{source:[],delay:500,queryBy:"name",items:10},i),this.options.insertFrom=this.options.insertFrom||this.options.queryBy,this.matcher=this.options.matcher||this.matcher,this.sorter=this.options.sorter||this.sorter,this.renderDropdown=this.options.renderDropdown||this.renderDropdown,this.render=this.options.render||this.render,this.insert=this.options.insert||this.insert,this.highlighter=this.options.highlighter||this.highlighter,this.query="",this.hasFocus=!0,this.renderInput(),this.bindEvents()};e.prototype={constructor:e,renderInput:function(){var t=''+this.options.delimiter+'\ufeff';this.editor.execCommand("mceInsertContent",!1,t),this.editor.focus(),this.editor.selection.select(this.editor.selection.dom.select("span#autocomplete-searchtext span")[0]),this.editor.selection.collapse(0)},bindEvents:function(){this.editor.on("keyup",this.editorKeyUpProxy=t.proxy(this.rteKeyUp,this)),this.editor.on("keydown",this.editorKeyDownProxy=t.proxy(this.rteKeyDown,this),!0),this.editor.on("click",this.editorClickProxy=t.proxy(this.rteClicked,this)),t("body").on("click",this.bodyClickProxy=t.proxy(this.rteLostFocus,this)),t(this.editor.getWin()).on("scroll",this.rteScroll=t.proxy(function(){this.cleanUp(!0)},this))},unbindEvents:function(){this.editor.off("keyup",this.editorKeyUpProxy),this.editor.off("keydown",this.editorKeyDownProxy),this.editor.off("click",this.editorClickProxy),t("body").off("click",this.bodyClickProxy),t(this.editor.getWin()).off("scroll",this.rteScroll)},rteKeyUp:function(t){switch(t.which||t.keyCode){case 40:case 38:case 16:case 17:case 18:break;case 8:""===this.query?this.cleanUp(!0):this.lookup();break;case 9:case 13:var e=void 0!==this.$dropdown?this.$dropdown.find("li.active"):[];e.length?(this.select(e.data()),this.cleanUp(!1)):this.cleanUp(!0);break;case 27:this.cleanUp(!0);break;default:this.lookup()}},rteKeyDown:function(t){switch(t.which||t.keyCode){case 9:case 13:case 27:t.preventDefault();break;case 38:t.preventDefault(),void 0!==this.$dropdown&&this.highlightPreviousResult();break;case 40:t.preventDefault(),void 0!==this.$dropdown&&this.highlightNextResult()}t.stopPropagation()},rteClicked:function(e){var i=t(e.target);this.hasFocus&&"autocomplete-searchtext"!==i.parent().attr("id")&&this.cleanUp(!0)},rteLostFocus:function(){this.hasFocus&&this.cleanUp(!0)},lookup:function(){this.query=t.trim(t(this.editor.getBody()).find("#autocomplete-searchtext").text()).replace("\ufeff",""),void 0===this.$dropdown&&this.show(),clearTimeout(this.searchTimeout),this.searchTimeout=setTimeout(t.proxy(function(){var e=t.isFunction(this.options.source)?this.options.source(this.query,t.proxy(this.process,this),this.options.delimiter):this.options.source;e&&this.process(e)},this),this.options.delay)},matcher:function(t){return~t[this.options.queryBy].toLowerCase().indexOf(this.query.toLowerCase())},sorter:function(t){for(var e,i=[],o=[],s=[];void 0!==(e=t.shift());)e[this.options.queryBy].toLowerCase().indexOf(this.query.toLowerCase())?~e[this.options.queryBy].indexOf(this.query)?o.push(e):s.push(e):i.push(e);return i.concat(o,s)},highlighter:function(t){return t.replace(new RegExp("("+this.query.replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1")+")","ig"),function(t,e){return""+e+""})},show:function(){var e=this.editor.inline?this.offsetInline():this.offset();this.$dropdown=t(this.renderDropdown()).css({top:e.top,left:e.left}),t("body").append(this.$dropdown),this.$dropdown.on("click",t.proxy(this.autoCompleteClick,this))},process:function(e){if(this.hasFocus){var i=this,o=[],s=t.grep(e,function(t){return i.matcher(t)});s=i.sorter(s),s=s.slice(0,this.options.items),t.each(s,function(e,n){var r=t(i.render(n,e));r.html(r.html().replace(r.text(),i.highlighter(r.text()))),t.each(s[e],function(t,e){r.attr("data-"+t,e)}),o.push(r[0].outerHTML)}),o.length?this.$dropdown.html(o.join("")).show():this.$dropdown.hide()}},renderDropdown:function(){return''},render:function(t,e){return'
  • '+t[this.options.queryBy]+"
  • "},autoCompleteClick:function(e){var i=t(e.target).closest("li").data();t.isEmptyObject(i)||(this.select(i),this.cleanUp(!1)),e.stopPropagation(),e.preventDefault()},highlightPreviousResult:function(){var t=this.$dropdown.find("li.active").index(),e=0===t?this.$dropdown.find("li").length-1:--t;this.$dropdown.find("li").removeClass("active").eq(e).addClass("active")},highlightNextResult:function(){var t=this.$dropdown.find("li.active").index(),e=t===this.$dropdown.find("li").length-1?0:++t;this.$dropdown.find("li").removeClass("active").eq(e).addClass("active")},select:function(t){this.editor.focus();var e=this.editor.dom.select("span#autocomplete")[0];this.editor.dom.remove(e),this.editor.execCommand("mceInsertContent",!1,this.insert(t))},insert:function(t){return""+t[this.options.insertFrom]+" "},cleanUp:function(e){if(this.unbindEvents(),this.hasFocus=!1,void 0!==this.$dropdown&&(this.$dropdown.remove(),delete this.$dropdown),e){var i=this.query,o=t(this.editor.dom.select("span#autocomplete"));if(!o.length)return;var s=t("

    "+this.options.delimiter+i+"

    ")[0].firstChild,n=t(this.editor.selection.getNode()).offset().top===o.offset().top+(o.outerHeight()-o.height())/2;this.editor.dom.replace(s,o[0]),n&&(this.editor.selection.select(s),this.editor.selection.collapse())}},offset:function(){var e=t(this.editor.getContainer()).offset(),i=t(this.editor.getContentAreaContainer()).position(),o=t(this.editor.dom.select("span#autocomplete")).position();return{top:e.top+i.top+o.top+t(this.editor.selection.getNode()).innerHeight()-t(this.editor.getDoc()).scrollTop()+5,left:e.left+i.left+o.left}},offsetInline:function(){var e=t(this.editor.dom.select("span#autocomplete")).offset();return{top:e.top+t(this.editor.selection.getNode()).innerHeight()+5,left:e.left}}},tinymce.create("tinymce.plugins.Mention",{init:function(i){function o(){var e=i.selection.getRng(!0).startOffset,o=(i.selection.getRng(!0).startContainer.data||"").substr(e>0?e-1:0,1);return!t.trim(o).length}var s,n=i.getParam("mentions");n.delimiter=void 0!==n.delimiter?t.isArray(n.delimiter)?n.delimiter:[n.delimiter]:["@"],i.on("keypress",function(r){var h=t.inArray(String.fromCharCode(r.which||r.keyCode),n.delimiter);h>-1&&o()&&(void 0===s||void 0!==s.hasFocus&&!s.hasFocus)&&(r.preventDefault(),s=new e(i,t.extend({},n,{delimiter:n.delimiter[h]})))})},getInfo:function(){return{longname:"mention",author:"Steven Devooght",version:tinymce.majorVersion+"."+tinymce.minorVersion}}}),tinymce.PluginManager.add("mention",tinymce.plugins.Mention)}); diff --git a/resources/views/account/notifications.blade.php b/resources/views/account/notifications.blade.php index 16056bb3c6..e833ebc61d 100644 --- a/resources/views/account/notifications.blade.php +++ b/resources/views/account/notifications.blade.php @@ -28,7 +28,8 @@ {!! Form::submit('x clear', ['class' => 'badge btn-primary', 'style' => 'display:inline; border: 0;']) !!} {!! Form::close() !!} - {{ config('lorekeeper.notifications.' . $type . '.name') }} + {{ config('lorekeeper.notifications.' . $type . '.name') ?? 'Unknown Notification' }}
    diff --git a/resources/views/admin/emotes/_delete_emote.blade.php b/resources/views/admin/emotes/_delete_emote.blade.php new file mode 100644 index 0000000000..6f08dd1370 --- /dev/null +++ b/resources/views/admin/emotes/_delete_emote.blade.php @@ -0,0 +1,13 @@ +@if ($emote) + {!! Form::open(['url' => 'admin/emotes/delete/' . $emote->id]) !!} + +

    Are you sure you want to delete {{ $emote->name }}?

    + +
    + {!! Form::submit('Delete Emote', ['class' => 'btn btn-danger']) !!} +
    + + {!! Form::close() !!} +@else + Invalid emote selected. +@endif diff --git a/resources/views/admin/emotes/create_edit_emote.blade.php b/resources/views/admin/emotes/create_edit_emote.blade.php new file mode 100644 index 0000000000..4357824799 --- /dev/null +++ b/resources/views/admin/emotes/create_edit_emote.blade.php @@ -0,0 +1,101 @@ +@extends('admin.layout') + +@section('admin-title') + Emotes +@endsection + +@section('admin-content') + {!! breadcrumbs(['Admin Panel' => 'admin', 'Emotes' => 'admin/emotes', ($emote->id ? 'Edit' : 'Create') . ' Emote' => $emote->id ? 'admin/emotes/edit/' . $emote->id : 'admin/emotes/create']) !!} + +

    {{ $emote->id ? 'Edit' : 'Create' }} Emote + @if ($emote->id) + Delete Emote + @endif +

    + + {!! Form::open(['url' => $emote->id ? 'admin/emotes/edit/' . $emote->id : 'admin/emotes/create', 'files' => true]) !!} + +

    Basic Information

    + +
    +
    + {!! Form::label('Image (Required)') !!} +
    {!! Form::file('image') !!}
    + @if ($emote->id) +
    + {!! Form::checkbox('remove_image', 1, false, ['class' => 'form-check-input']) !!} + {!! Form::label('remove_image', 'Remove current image', ['class' => 'form-check-label']) !!} +
    + @endif +
    +
    + {!! Form::label('Name') !!} + {!! Form::text('name', $emote->name, ['class' => 'form-control']) !!} +
    +
    + +
    + {!! Form::label('Alt Text') !!} + {!! Form::text('alt_text', $emote->alt_text, ['class' => 'form-control']) !!} +
    + +
    + {!! Form::label('description') !!} + {!! Form::textarea('description', $emote->description, ['class' => 'form-control wysiwyg']) !!} +
    + +
    + {!! Form::checkbox('is_active', 1, $emote->id ? $emote->is_active : 1, ['class' => 'form-check-input', 'data-toggle' => 'toggle']) !!} + {!! Form::label('is_active', 'Set Active', ['class' => 'form-check-label ml-3']) !!} {!! add_help('If turned off, this emote will not be able to be used') !!} +
    + +
    + {!! Form::submit($emote->id ? 'Edit' : 'Create', ['class' => 'btn btn-primary']) !!} +
    + + {!! Form::close() !!} + + @if ($emote->id && $emote->imageUrl) +

    Preview

    +
    +
    +
    + +
    + {!! $emote->getImage() !!} +
    +

    + {!! $emote->name !!} +

    +
    + {!! $emote->description !!} +
    +
    +
    +
    Use This Emote
    +
    +
    + In the rich text editor: +
    + :{{ $emote->name }} +
    +
    +
    +
    +
    +
    + @endif +@endsection + +@section('scripts') + @parent + +@endsection diff --git a/resources/views/admin/emotes/emotes.blade.php b/resources/views/admin/emotes/emotes.blade.php new file mode 100644 index 0000000000..2896dc5123 --- /dev/null +++ b/resources/views/admin/emotes/emotes.blade.php @@ -0,0 +1,75 @@ +@extends('admin.layout') + +@section('admin-title') + Emotes +@endsection + +@section('admin-content') + {!! breadcrumbs(['Admin Panel' => 'admin', 'Emotes' => 'admin/emotes']) !!} + +

    Emotes

    + +

    This is a list of emotes players/admins can use in the tinymce editor (and in comments).

    + + + +
    + {!! Form::open(['method' => 'GET', 'class' => '']) !!} +
    +
    + {!! Form::text('name', Request::get('name'), ['class' => 'form-control', 'placeholder' => 'Name']) !!} +
    +
    + {!! Form::close() !!} +
    + + @if (!count($emotes)) +

    No emotes found.

    + @else + {!! $emotes->render() !!} +
    +
    +
    +
    +
    Name
    +
    +
    +
    Description
    +
    +
    +
    +
    + @foreach ($emotes as $emote) +
    +
    +
    +
    + @if (!$emote->is_released) + + @endif + {{ $emote->name }} +
    +
    +
    +
    {{ $emote->description ? $emote->description : 'No description' }}
    +
    +
    +
    + Edit +
    +
    +
    +
    + @endforeach +
    +
    + {!! $emotes->render() !!} + @endif + +@endsection + +@section('scripts') + @parent +@endsection diff --git a/resources/views/character/_image_info.blade.php b/resources/views/character/_image_info.blade.php index 3b9fd18407..16e06d9fff 100644 --- a/resources/views/character/_image_info.blade.php +++ b/resources/views/character/_image_info.blade.php @@ -186,28 +186,16 @@
    In the rich text editor:
    - [character={{ $character->slug }}] + {{ '@' . $character->fullName }}
    - @if (!config('lorekeeper.settings.wysiwyg_comments')) - In a comment: -
    - [{{ $character->fullName }}]({{ $character->url }}) -
    - @endif
    For Thumbnails:
    In the rich text editor:
    - [charthumb={{ $character->slug }}] + #{{ $character->fullName }}
    - @if (!config('lorekeeper.settings.wysiwyg_comments')) - In a comment: -
    - [![Thumbnail of {{ $character->fullName }}]({{ $character->image->thumbnailUrl }})]({{ $character->url }}) -
    - @endif
    @endif diff --git a/resources/views/comments/_actions.blade.php b/resources/views/comments/_actions.blade.php index 029e5575f9..da71ed7f75 100644 --- a/resources/views/comments/_actions.blade.php +++ b/resources/views/comments/_actions.blade.php @@ -70,8 +70,8 @@ class="ml-2 d-none d-sm-inline-block">Delete diff --git a/resources/views/world/_sidebar.blade.php b/resources/views/world/_sidebar.blade.php index 13a6f5ec4d..3719269d06 100644 --- a/resources/views/world/_sidebar.blade.php +++ b/resources/views/world/_sidebar.blade.php @@ -19,4 +19,8 @@ + diff --git a/resources/views/world/emotes.blade.php b/resources/views/world/emotes.blade.php new file mode 100644 index 0000000000..02156fbdb6 --- /dev/null +++ b/resources/views/world/emotes.blade.php @@ -0,0 +1,57 @@ +@extends('world.layout') + +@section('title') + Emotes +@endsection + +@section('content') + {!! breadcrumbs(['World' => 'world', 'Emotes' => 'world/emotes']) !!} +

    Emotes

    + +
    + {!! Form::open(['method' => 'GET', 'class' => 'form-inline justify-content-end']) !!} +
    + {!! Form::text('name', Request::get('name'), ['class' => 'form-control', 'placeholder' => 'Search by Name']) !!} +
    + {!! Form::close() !!} +
    + +

    Emotes that you can use on world pages or the tinymce editor.

    + + + {!! $emotes->render() !!} +
    + @foreach ($emotes as $emote) +
    +
    +
    + +
    + {!! $emote->getImage() !!} +
    +

    + {!! $emote->name !!} +

    +
    + {!! $emote->description !!} +
    +
    +
    +
    Use This Emote
    +
    +
    + In the rich text editor: +
    + :{{ $emote->name }} +
    +
    +
    +
    +
    +
    + @endforeach +
    + {!! $emotes->render() !!} + +
    {{ count($emotes) }} result{{ count($emotes) == 1 ? '' : 's' }} found.
    +@endsection diff --git a/routes/lorekeeper/admin.php b/routes/lorekeeper/admin.php index c1a5d3946a..470316e45c 100644 --- a/routes/lorekeeper/admin.php +++ b/routes/lorekeeper/admin.php @@ -492,3 +492,14 @@ Route::group(['prefix' => 'limits', 'middleware' => 'power:manage_data'], function () { Route::post('/', 'LimitController@postCreateEditLimits'); }); + +// EMOTES +Route::group(['prefix' => 'emotes', 'middleware' => 'power:manage_data'], function () { + Route::get('/', 'EmoteController@getEmoteIndex'); + Route::get('create', 'EmoteController@getCreateEmote'); + Route::post('create', 'EmoteController@postCreateEditEmote'); + Route::get('edit/{id}', 'EmoteController@getEditEmote'); + Route::post('edit/{id}', 'EmoteController@postCreateEditEmote'); + Route::get('delete/{id}', 'EmoteController@getDeleteEmote'); + Route::post('delete/{id}', 'EmoteController@postDeleteEmote'); +}); diff --git a/routes/lorekeeper/browse.php b/routes/lorekeeper/browse.php index 6d00e32dd3..2f33a54bff 100644 --- a/routes/lorekeeper/browse.php +++ b/routes/lorekeeper/browse.php @@ -42,9 +42,10 @@ /************************************************************************************************** Users **************************************************************************************************/ -Route::get('/users', 'BrowseController@getUsers'); -Route::get('/blacklist', 'BrowseController@getBlacklist'); -Route::get('/deactivated-list', 'BrowseController@getDeactivated'); +Route::get('users', 'BrowseController@getUsers'); +Route::get('mentions', 'BrowseController@getSearchMentions'); +Route::get('blacklist', 'BrowseController@getBlacklist'); +Route::get('deactivated-list', 'BrowseController@getDeactivated'); // PROFILES Route::group(['prefix' => 'user', 'namespace' => 'Users'], function () { @@ -118,6 +119,7 @@ Route::get('traits', 'WorldController@getFeatures'); Route::get('traits/modal/{id}', 'WorldController@getFeatureDetail')->where(['id' => '[0-9]+']); Route::get('character-categories', 'WorldController@getCharacterCategories'); + Route::get('emotes', 'WorldController@getEmotes'); }); Route::group(['prefix' => 'prompts'], function () {