From 80d6f2e921143f91f3639c1d47deed310986166e Mon Sep 17 00:00:00 2001 From: Yury Pikhtarev Date: Wed, 16 Jul 2025 21:52:20 +0400 Subject: [PATCH] Add emoji management functionality and UI components (#2048) * Add emoji management functionality and UI components - Introduced new controllers for managing emojis, categories, and aliases, enhancing the backend functionality for emoji operations. - Created corresponding views and forms for emoji creation and editing, allowing for a user-friendly interface. - Implemented a data table for displaying emojis with sorting and filtering capabilities. - Added routes for emoji management in the admin panel, streamlining the administrative workflow. - Updated package dependencies to include @tanstack/react-table for improved table handling. These changes significantly enhance the emoji management system, providing a robust interface for administrators to manage emojis effectively. * Refactor emoji-related components and clean up code - Updated the EmojiAliasController to clarify that the show method is not needed for Inertia, as it is handled in the frontend. - Removed unused 'use client' directive from the emoji data table component. - Deleted the 'Emojis' entry from the sidebar navigation in the settings layout, streamlining the UI. These changes improve code clarity and maintainability by removing unnecessary elements and comments. --- .../Admin/Emoji/EmojiAliasController.php | 74 + .../Admin/Emoji/EmojiCategoryController.php | 93 + .../Admin/Emoji/EmojiController.php | 142 + .../Emoji/EmojiAliasController.php | 67 - .../Emoji/EmojiCategoryController.php | 67 - .../Controllers/Emoji/EmojiController.php | 67 - app/Http/Requests/Emoji/StoreEmojiRequest.php | 2 + .../Requests/Emoji/UpdateEmojiRequest.php | 2 + composer.lock | 18 +- legacy/data/avatars/gallery/bot.gif | Bin 13427 -> 0 bytes legacy/data/avatars/gallery/noavatar.png | Bin 7853 -> 0 bytes legacy/styles/images/ranks/admin.png | Bin 3223 -> 0 bytes legacy/styles/images/ranks/user.png | Bin 2860 -> 0 bytes legacy/styles/templates/posting_tpl.tpl | 5176 ----------------- package-lock.json | 34 + package.json | 1 + resources/js/components/app-sidebar.tsx | 7 +- resources/js/components/emoji-data-table.tsx | 272 + resources/js/components/ui/table.tsx | 114 + resources/js/pages/admin/emojis/form.tsx | 393 ++ resources/js/pages/admin/emojis/index.tsx | 161 + resources/js/pages/admin/index.tsx | 177 + resources/js/pages/dashboard.tsx | 2 +- resources/js/types/index.d.ts | 7 + routes/admin.php | 33 + routes/web.php | 1 + 26 files changed, 1522 insertions(+), 5388 deletions(-) create mode 100644 app/Http/Controllers/Admin/Emoji/EmojiAliasController.php create mode 100644 app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php create mode 100644 app/Http/Controllers/Admin/Emoji/EmojiController.php delete mode 100644 app/Http/Controllers/Emoji/EmojiAliasController.php delete mode 100644 app/Http/Controllers/Emoji/EmojiCategoryController.php delete mode 100644 app/Http/Controllers/Emoji/EmojiController.php delete mode 100644 legacy/data/avatars/gallery/bot.gif delete mode 100644 legacy/data/avatars/gallery/noavatar.png delete mode 100644 legacy/styles/images/ranks/admin.png delete mode 100644 legacy/styles/images/ranks/user.png delete mode 100644 legacy/styles/templates/posting_tpl.tpl create mode 100644 resources/js/components/emoji-data-table.tsx create mode 100644 resources/js/components/ui/table.tsx create mode 100644 resources/js/pages/admin/emojis/form.tsx create mode 100644 resources/js/pages/admin/emojis/index.tsx create mode 100644 resources/js/pages/admin/index.tsx create mode 100644 routes/admin.php diff --git a/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php b/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php new file mode 100644 index 000000000..41c9f3683 --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php @@ -0,0 +1,74 @@ +validated()); + + return back()->with('success', 'Alias added successfully.'); + } + + /** + * Display the specified resource. + */ + public function show(EmojiAlias $alias) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(EmojiAlias $alias) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiAliasRequest $request, EmojiAlias $alias): RedirectResponse + { + $alias->update($request->validated()); + + return back()->with('success', 'Alias updated successfully.'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(EmojiAlias $alias): RedirectResponse + { + $alias->delete(); + + return back()->with('success', 'Alias removed successfully.'); + } +} diff --git a/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php b/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php new file mode 100644 index 000000000..3bf1b62ac --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php @@ -0,0 +1,93 @@ +orderBy('display_order') + ->get(); + + return response()->json($categories); + } + + /** + * Show the form for creating a new resource. + */ + public function create() + { + // Not needed for Inertia - handled in frontend + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreEmojiCategoryRequest $request): JsonResponse + { + $category = EmojiCategory::create($request->validated()); + + return response()->json([ + 'message' => 'Category created successfully.', + 'category' => $category, + ], 201); + } + + /** + * Display the specified resource. + */ + public function show(EmojiCategory $category) + { + return response()->json($category->load('emojis')); + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(EmojiCategory $category) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiCategoryRequest $request, EmojiCategory $category): JsonResponse + { + $category->update($request->validated()); + + return response()->json([ + 'message' => 'Category updated successfully.', + 'category' => $category, + ]); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(EmojiCategory $category): JsonResponse + { + if ($category->emojis()->count() > 0) { + return response()->json([ + 'message' => 'Cannot delete category with existing emojis.', + ], 422); + } + + $category->delete(); + + return response()->json([ + 'message' => 'Category deleted successfully.', + ]); + } +} diff --git a/app/Http/Controllers/Admin/Emoji/EmojiController.php b/app/Http/Controllers/Admin/Emoji/EmojiController.php new file mode 100644 index 000000000..31eb30679 --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiController.php @@ -0,0 +1,142 @@ +orderBy('created_at', 'desc'); + + // Search functionality + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('emoji_shortcode', 'like', "%{$search}%") + ->orWhere('emoji_text', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + // Category filter + if ($categoryId = $request->get('category_id')) { + $query->where('emoji_category_id', $categoryId); + } + + $emojis = $query->paginate(20)->withQueryString(); + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + + return Inertia::render('admin/emojis/index', [ + 'emojis' => $emojis, + 'categories' => $categories, + 'filters' => $request->only(['search', 'category_id']), + ]); + } + + /** + * Show the form for creating a new resource. + */ + public function create(): Response + { + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + + return Inertia::render('admin/emojis/form', [ + 'categories' => $categories, + 'emoji' => null, + ]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreEmojiRequest $request): RedirectResponse + { + $data = $request->validated(); + + // Handle image upload if present + if ($request->hasFile('image')) { + $path = $request->file('image')->store('emojis', 'public'); + $data['image_url'] = $path; + } + + // Set sprite_mode based on sprite_params + if (isset($data['sprite_params']) && !empty($data['sprite_params'])) { + $data['sprite_mode'] = true; + } + + Emoji::create($data); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji created successfully.'); + } + + /** + * Display the specified resource. + */ + public function show(Emoji $emoji): Response + { + return redirect()->route('admin.emojis.edit', $emoji); + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(Emoji $emoji): Response + { + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + $emoji->load('aliases'); + + return Inertia::render('admin/emojis/form', [ + 'categories' => $categories, + 'emoji' => $emoji, + ]); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiRequest $request, Emoji $emoji): RedirectResponse + { + $data = $request->validated(); + + // Handle image upload if present + if ($request->hasFile('image')) { + $path = $request->file('image')->store('emojis', 'public'); + $data['image_url'] = $path; + } + + // Set sprite_mode based on sprite_params + if (isset($data['sprite_params']) && !empty($data['sprite_params'])) { + $data['sprite_mode'] = true; + } + + $emoji->update($data); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji updated successfully.'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Emoji $emoji): RedirectResponse + { + $emoji->delete(); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Emoji/EmojiAliasController.php b/app/Http/Controllers/Emoji/EmojiAliasController.php deleted file mode 100644 index 32e661349..000000000 --- a/app/Http/Controllers/Emoji/EmojiAliasController.php +++ /dev/null @@ -1,67 +0,0 @@ -toArray()), ], + 'image' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,gif,webp', 'image_url' => 'nullable|string|max:500', 'sprite_mode' => 'boolean', 'sprite_params' => 'nullable|array', @@ -55,6 +56,7 @@ class StoreEmojiRequest extends FormRequest 'emoji_shortcode.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)', 'emoji_shortcode.unique' => 'This shortcode is already taken.', 'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.', + 'image.max' => 'The image must not be larger than 2MB.', ]; } } diff --git a/app/Http/Requests/Emoji/UpdateEmojiRequest.php b/app/Http/Requests/Emoji/UpdateEmojiRequest.php index d4b0c3958..ae966bf09 100644 --- a/app/Http/Requests/Emoji/UpdateEmojiRequest.php +++ b/app/Http/Requests/Emoji/UpdateEmojiRequest.php @@ -35,6 +35,7 @@ class UpdateEmojiRequest extends FormRequest Rule::unique('emojis', 'emoji_shortcode')->ignore($emojiId), Rule::notIn(\App\Models\EmojiAlias::pluck('alias')->toArray()), ], + 'image' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,gif,webp', 'image_url' => 'nullable|string|max:500', 'sprite_mode' => 'sometimes|boolean', 'sprite_params' => 'nullable|array', @@ -57,6 +58,7 @@ class UpdateEmojiRequest extends FormRequest 'emoji_shortcode.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)', 'emoji_shortcode.unique' => 'This shortcode is already taken.', 'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.', + 'image.max' => 'The image must not be larger than 2MB.', ]; } } diff --git a/composer.lock b/composer.lock index 52e46f6fc..c71c95775 100644 --- a/composer.lock +++ b/composer.lock @@ -7990,16 +7990,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -8031,9 +8031,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9645,12 +9645,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.4" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/legacy/data/avatars/gallery/bot.gif b/legacy/data/avatars/gallery/bot.gif deleted file mode 100644 index c426414f1447a84c59792dbe8167118877fb601b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13427 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 diff --git a/legacy/data/avatars/gallery/noavatar.png b/legacy/data/avatars/gallery/noavatar.png deleted file mode 100644 index acbe7c294520369f578f03ee804de8ea7eab0827..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7853 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 diff --git a/legacy/styles/images/ranks/user.png b/legacy/styles/images/ranks/user.png deleted file mode 100644 index 1ec271e67e990282061e13279c0c880a94185e06..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2860 zcmbVOc{o&iAD=?X8mYz-)3}6~ePNK88F4k5hO&f=nK342X=X4=vSkTTy2@TEl;z5m z^?DU1Dp^v>7MEzbh+N^dbVudB?;rQM@AJIpdCvJQ=kxh|*WYnjYV@)-bYI@gB*vS(A73<`rv4>|dWVF3V0 z(OJ%}LRZp39F5C{P}eXJ5t}Dr0{|9QA|93I&k%xq7)%z&68y2Q0Ssc%Ex|5m5{$&d zGyGU~r}zw~Q;yEGQ~oqFI@oF-$U=mZ2(TGKDoDf*;0SOcOYm1+oMgVX4F!X~LWKU7 z;BQX3lE@%Dm(Kv9Autmf3LrA8w$~5-jobTL^3(iS(B+NANvSl9WM3R2~!#fkD~qwYa{b1wsnr zKW_XjTHqYQV?Zek0XL9OlhngkcbzQB-9H;z14_KX9pbYjMWF@|xU@hvgCn#jSb`;Q zAaoWThsB`KW;WKgXab5rgu~(1rnYb*25SbhCZKI?3CMMgzvE(TQE;TKDIP_@AmMN# z+!SMjwnk%NSTl(T3TC>_wdV+gR1S@?ZkHvo`<-j^k6av{&!7sqd}l5Z13sK0UP|4+uC5@(=m zt?^&2vc4thzqRRKjW1dJb#xdU$$8;R4#sO4Ukw02*2|t??JVk>aPtpvCaSIa9v>VG zs&lBDOE5r2lS7FvZae4ZyXAH zr#zbFTF>k~u5)kO3$&@gBG|FS*zNEU?FLU()Op(TeB>*iGNIU zhdYvn&SE&d$X^3`V>%$UX^~H-TvJj<`#P~OXTh|cOW|b4RJnHq_jP$clbfNQepf#o z7?hOBPXyCnmU=GlYdz1bP>O1@51pay`4KPUcY<{Q0hca#B2~3V)@iTJRpjsB<*rj| z#1IV+$Cheh$MT4CAiBz1NA^Z-twvDY8N$y){e3D)7;g$|D-v4N+&;Jjv<&q2D^o5Z zDWOoS5g%HKrXfb>mUbSu;`AV`BX1?;kNYBk36o=&#}WwO$fXde7lA=9lbGk}hKql>>Rmi}JO+vQtt;8Aef1^ZBA zbgZt|5t)$kbFPcHqJ-1uUaWj|A3MX__yHV~Niw0xBrivFzgL@gHIB*eF@O{s;}4es zY7)zT#BWgB^hx^23lFc|iHbbE==kxo6WOZQ$^UY=o0m>%D~#w$QXW&xKY`wE^V8m2 zL-AcCs}6p2hJ_Rnc13jMZAN4=ip!zuQOTmt%qDg zM*7KkxhnnSb6w;XU z5P$F}&Gt-nXKYK_VE7&RNT!tPn2;}S8;Lg9qZaO$5tad$4m+zY_sR2`nU}Mp+=HpI z-O!hc!55@v2ozy*ODdufHTg#>z>>7mP*-h>7T?o<=Zng8uJ#?(rnZ(qO&-X9oZ32k zFx@q-^?AHapmxH$`P`b|=!roEyRiD@R(7FLD!^i4Kvons=pqu#UHav69V`EOph0Cy zcpQ;3cG36j7A@<+EdMf2Q$b--jePvgn21-ffMU2fTn#0T=99N~UN$s(p-Uk?}&s`e{d86*I zu^7oj^zT7}8q9gp1)Dahr)p<4!zIh|y;k<@FKCML58Ki$tr zLiWjuxYWv5eRBXhMF$9;mLK$$mYKKs62P-@5sfZtr!e%E3zcsLx3EX9{oOmRR7l z+~9+7*`h1QV}Y~NRaL8T^$prI6@f=Z`be>$B4@+G+yH)7ywZEm{2T?styraPj4N+y zT{ha6&GQ=vI;5UeqE5TH??ex)_CrUmS%*v7!TZ0x+p-+^>!DVx z?sf1)i_4Od&wD4$>7KMR)39k@5_B7z7~v5$Rrq*YFY)k>R5$HUqY;@*M6ah!kKf7{ z|BL>P_U3*&GBq`e6dsrBXQ$y02Q2ZSeRZCxgW*BicftOn$IU3b{=kecC2(q z?EZr8_D{BQ+G#EeoT4zMuF~B&Lr5F5pQc<|SMJfNw3{s3I8pY4TdLE17X|=s0S?pk znz}FwZu+rT!!1Ewk)2@EWciq~IF*ciA>zSRMGt&|JW$ESXkT08I Y0KyRBQb&F+e(nF&p6Ey@wegPp6L&t#%m4rY diff --git a/legacy/styles/templates/posting_tpl.tpl b/legacy/styles/templates/posting_tpl.tpl deleted file mode 100644 index 1bd379ba2..000000000 --- a/legacy/styles/templates/posting_tpl.tpl +++ /dev/null @@ -1,5176 +0,0 @@ - - - - -

{FORUM_NAME}

- - - - -
- - - -
- - - - - - - - - -
Правила оформления
-
{TPL_RULES_HTML}
-
-
-
- - - - - --- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Создание шаблона для релиза
-
-
- - -
название: [?]
- -
- -
- - - - -
-
-
-
-

сообщение: [?]

- -
-
-
-
-
[ Инструкция ]
- -
-
- - -
-
- - - - -
-
- - - - - - - - - - - - - - - -
- -
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - diff --git a/package-lock.json b/package-lock.json index 57b5577c4..0f1a09198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-table": "^8.21.3", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", @@ -2574,6 +2575,26 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.13.2", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.2.tgz", @@ -2591,6 +2612,19 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-core": { "version": "3.13.2", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz", diff --git a/package.json b/package.json index 1b8f1223c..ebc71a35c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", "@tailwindcss/vite": "^4.0.6", + "@tanstack/react-table": "^8.21.3", "@types/react": "^19.0.3", "@types/react-dom": "^19.0.2", "@vitejs/plugin-react": "^4.3.4", diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index c51767276..c9213dfbd 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -4,7 +4,7 @@ import { NavUser } from '@/components/nav-user'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, Folder, LayoutGrid } from 'lucide-react'; +import { BookOpen, Folder, LayoutGrid, Settings } from 'lucide-react'; import AppLogo from './app-logo'; const mainNavItems: NavItem[] = [ @@ -26,6 +26,11 @@ const footerNavItems: NavItem[] = [ href: 'https://laravel.com/docs/starter-kits#react', icon: BookOpen, }, + { + title: 'Control Panel', + href: '/admin', + icon: Settings, + }, ]; export function AppSidebar() { diff --git a/resources/js/components/emoji-data-table.tsx b/resources/js/components/emoji-data-table.tsx new file mode 100644 index 000000000..85d5a89f2 --- /dev/null +++ b/resources/js/components/emoji-data-table.tsx @@ -0,0 +1,272 @@ +import { + ColumnDef, + ColumnFiltersState, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { ArrowUpDown, Edit, MoreHorizontal, Trash2 } from 'lucide-react'; +import { useState } from 'react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { router } from '@inertiajs/react'; + +export type SpriteParams = { + x?: number; + y?: number; + width?: number; + height?: number; + sprite_sheet_url?: string; +} & Record; + +export type Emoji = { + id: number; + title: string; + emoji_shortcode: string; + emoji_text?: string; + image_url?: string; + sprite_mode?: boolean; + sprite_params?: SpriteParams; + emoji_category_id?: number; + category?: { + id: number; + title: string; + }; + aliases?: { + id: number; + alias: string; + }[]; + created_at: string; +}; + +interface DataTableProps { + data: Emoji[]; + routePrefix?: string; +} + +export function EmojiDataTable({ data, routePrefix = 'emojis' }: DataTableProps) { + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); + + const columns: ColumnDef[] = [ + { + accessorKey: 'emoji_shortcode', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) =>
{row.getValue('emoji_shortcode')}
, + }, + { + accessorKey: 'title', + header: 'Title', + cell: ({ row }) =>
{row.getValue('title')}
, + }, + { + accessorKey: 'emoji_text', + header: 'Display', + cell: ({ row }) => { + const emoji = row.original; + if (emoji.emoji_text) { + return
{emoji.emoji_text}
; + } + if (emoji.image_url) { + return {emoji.emoji_shortcode}; + } + return
N/A
; + }, + }, + { + accessorKey: 'sprite_mode', + header: 'Type', + cell: ({ row }) => { + const emoji = row.original; + let type = 'text'; + if (emoji.sprite_mode) type = 'sprite'; + else if (emoji.image_url) type = 'image'; + + return {type}; + }, + }, + { + accessorKey: 'category', + header: 'Category', + cell: ({ row }) => { + const category = row.original.category; + return category ? {category.title} : None; + }, + }, + { + accessorKey: 'aliases', + header: 'Aliases', + cell: ({ row }) => { + const aliases = row.original.aliases || []; + if (aliases.length === 0) { + return None; + } + return ( +
+ {aliases.slice(0, 2).map((alias) => ( + + {alias.alias} + + ))} + {aliases.length > 2 && ( + + +{aliases.length - 2} more + + )} +
+ ); + }, + }, + { + accessorKey: 'created_at', + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const date = new Date(row.getValue('created_at')); + return
{date.toLocaleDateString()}
; + }, + }, + { + id: 'actions', + cell: ({ row }) => { + const emoji = row.original; + + return ( + + + + + + Actions + navigator.clipboard.writeText(emoji.emoji_shortcode)}>Copy shortcode + + router.get(route(`${routePrefix}.edit`, emoji.id))}> + + Edit + + { + if (confirm('Are you sure you want to delete this emoji?')) { + router.delete(route(`${routePrefix}.destroy`, emoji.id)); + } + }} + className="text-red-600" + > + + Delete + + + + ); + }, + }, + ]; + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + state: { + sorting, + columnFilters, + columnVisibility, + }, + }); + + return ( +
+
+ table.getColumn('emoji_shortcode')?.setFilterValue(event.target.value)} + className="max-w-sm" + /> +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+
+
{table.getFilteredRowModel().rows.length} emoji(s) total.
+
+ + +
+
+
+ ); +} diff --git a/resources/js/components/ui/table.tsx b/resources/js/components/ui/table.tsx new file mode 100644 index 000000000..5513a5cdb --- /dev/null +++ b/resources/js/components/ui/table.tsx @@ -0,0 +1,114 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Table({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ) +} + +function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ) +} + +function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + + ) +} + +function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", + className + )} + {...props} + /> + ) +} + +function TableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ) +} + +function TableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ) +} + +function TableCaption({ + className, + ...props +}: React.ComponentProps<"caption">) { + return ( +
+ ) +} + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} diff --git a/resources/js/pages/admin/emojis/form.tsx b/resources/js/pages/admin/emojis/form.tsx new file mode 100644 index 000000000..54d7d7ab9 --- /dev/null +++ b/resources/js/pages/admin/emojis/form.tsx @@ -0,0 +1,393 @@ +import { type BreadcrumbItem, type SharedData } from '@/types'; +import { Head, Link, router, useForm, usePage } from '@inertiajs/react'; +import { Shield, Upload, X } from 'lucide-react'; +import { ChangeEvent, FormEventHandler, useState } from 'react'; + +import { type SpriteParams } from '@/components/emoji-data-table'; +import InputError from '@/components/input-error'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AppLayout from '@/layouts/app-layout'; + +type Category = { + id: number; + title: string; + display_order: number; +}; + +type Alias = { + id: number; + alias: string; +}; + +type Emoji = { + id: number; + title: string; + emoji_shortcode: string; + emoji_text?: string; + image_url?: string; + sprite_mode?: boolean; + sprite_params?: SpriteParams; + emoji_category_id?: number; + display_order: number; + aliases?: Alias[]; +}; + +type PageProps = { + categories: Category[]; + emoji?: Emoji; +}; + +type EmojiForm = { + title: string; + emoji_shortcode: string; + emoji_text: string; + image?: File; + sprite_mode: boolean; + sprite_params?: Record; + emoji_category_id: number | ''; + display_order: number; +}; + +export default function AdminEmojiForm({ categories, emoji }: PageProps) { + const { flash } = usePage().props; + const isEditing = !!emoji; + const [imagePreview, setImagePreview] = useState(null); + const [newAlias, setNewAlias] = useState(''); + + const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Control Panel', + href: '/admin', + }, + { + title: 'Emoji Management', + href: '/admin/emojis', + }, + { + title: isEditing ? 'Edit emoji' : 'Create emoji', + href: '#', + }, + ]; + + const { data, setData, post, patch, errors, processing } = useForm({ + title: emoji?.title || '', + emoji_shortcode: emoji?.emoji_shortcode || '', + emoji_text: emoji?.emoji_text || '', + sprite_mode: emoji?.sprite_mode || false, + sprite_params: (emoji?.sprite_params as Record) || {}, + emoji_category_id: emoji?.emoji_category_id || '', + display_order: emoji?.display_order || 0, + }); + + const submit: FormEventHandler = (e) => { + e.preventDefault(); + + if (isEditing) { + patch(route('admin.emojis.update', emoji.id), { + preserveScroll: true, + }); + } else { + post(route('admin.emojis.store'), { + preserveScroll: true, + }); + } + }; + + const handleImageChange = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setData('image', file); + setData('sprite_mode', false); + + const reader = new FileReader(); + reader.onload = (e) => { + setImagePreview(e.target?.result as string); + }; + reader.readAsDataURL(file); + } + }; + + const handleTypeChange = (type: 'text' | 'image' | 'sprite') => { + if (type === 'sprite') { + setData('sprite_mode', true); + } else { + setData('sprite_mode', false); + } + + if (type !== 'image') { + setImagePreview(null); + setData('image', undefined); + } + }; + + const addAlias = () => { + if (!newAlias.trim() || !emoji) return; + + router.post( + route('admin.emoji-aliases.store'), + { + emoji_id: emoji.id, + alias: newAlias, + }, + { + preserveScroll: true, + onSuccess: () => { + setNewAlias(''); + }, + }, + ); + }; + + const removeAlias = (aliasId: number) => { + if (confirm('Are you sure you want to remove this alias?')) { + router.delete(route('admin.emoji-aliases.destroy', aliasId), { + preserveScroll: true, + }); + } + }; + + return ( + + + +
+ {flash?.success && ( +
+
{flash.success}
+
+ )} + +
+
+
+

+ + {isEditing ? 'Edit emoji' : 'Create emoji'} +

+

Administrative emoji management - full control over emoji properties

+
+
+
+ +
+ {/* Basic Information */} + + + Basic Information + Configure the fundamental properties of this emoji + + +
+ + setData('title', e.target.value)} + placeholder="Emoji title" + required + /> + +
+ +
+ + setData('emoji_shortcode', e.target.value)} + placeholder=":example:" + required + /> + +

Must be in format :name: (e.g., :smile:)

+
+ +
+ + + +
+ +
+ + setData('display_order', Number(e.target.value))} + min="0" + /> + +

Lower numbers appear first in lists

+
+
+
+ + {/* Emoji Type */} + + + Emoji Type & Content + Choose how this emoji will be displayed to users + + +
+ + + +
+ + {!data.sprite_mode && !imagePreview && ( +
+ + setData('emoji_text', e.target.value)} + placeholder="😀" + className="text-2xl" + /> + +

Enter the Unicode emoji character

+
+ )} + + {!data.sprite_mode && ( +
+
+ +
+
+ {imagePreview ? ( +
+ Preview + +
+ ) : ( +
+ +
+ + +
+
+ )} +
+
+ +

PNG, JPG, GIF up to 2MB. Recommended size: 32x32px

+
+
+ )} +
+
+ + {/* Aliases - Only for editing */} + {isEditing && emoji?.aliases && ( + + + Aliases + Alternative shortcodes that users can use for this emoji + + + {emoji.aliases.length > 0 && ( +
+ {emoji.aliases.map((alias) => ( + + {alias.alias} + + + ))} +
+ )} + +
+ setNewAlias(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addAlias(); + } + }} + /> + +
+
+
+ )} + + {/* Actions */} +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/emojis/index.tsx b/resources/js/pages/admin/emojis/index.tsx new file mode 100644 index 000000000..3e26cb800 --- /dev/null +++ b/resources/js/pages/admin/emojis/index.tsx @@ -0,0 +1,161 @@ +import { type BreadcrumbItem, type SharedData } from '@/types'; +import { Head, Link, router, usePage } from '@inertiajs/react'; +import { Filter, Plus } from 'lucide-react'; +import { useState } from 'react'; + +import { EmojiDataTable, type Emoji } from '@/components/emoji-data-table'; +import HeadingSmall from '@/components/heading-small'; +import { Button } from '@/components/ui/button'; +import AppLayout from '@/layouts/app-layout'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Control Panel', + href: '/admin', + }, + { + title: 'Emoji Management', + href: '/admin/emojis', + }, +]; + +type Category = { + id: number; + title: string; + display_order: number; + emojis_count?: number; +}; + +type PaginationLink = { + url: string | null; + label: string; + active: boolean; +}; + +type PageProps = { + emojis: { + data: Emoji[]; + links: PaginationLink[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; + categories: Category[]; + filters: { + search?: string; + category_id?: number; + }; +}; + +export default function AdminEmojiIndex({ emojis, categories, filters }: PageProps) { + const { flash } = usePage().props; + const [categoryFilter, setCategoryFilter] = useState(filters.category_id?.toString() || ''); + + const handleCategoryFilter = (value: string) => { + setCategoryFilter(value); + const params = new URLSearchParams(window.location.search); + + if (value) { + params.set('category_id', value); + } else { + params.delete('category_id'); + } + + router.get(route('admin.emojis.index'), Object.fromEntries(params), { + preserveState: true, + replace: true, + }); + }; + + return ( + + + +
+ {flash?.success && ( +
+
{flash.success}
+
+ )} + +
+
+ +
+ + + +
+ + {/* Stats */} +
+
+
{emojis.total || 0}
+

Total emojis

+
+
+
{categories.length}
+

Categories

+
+
+
+ {emojis.data?.reduce((acc, emoji) => acc + (emoji.aliases?.length || 0), 0) || 0} +
+

Total aliases

+
+
+
{emojis.data?.filter((emoji) => emoji.sprite_mode).length || 0}
+

Sprite emojis

+
+
+ + {/* Advanced Filters */} +
+
+
+ + Filters: +
+ +
+
+ + {/* Data Table */} +
+ +
+ + {/* Pagination */} + {emojis.links && emojis.links.length > 3 && ( +
+ {emojis.links.map((link, index) => ( +
+ )} +
+
+ ); +} diff --git a/resources/js/pages/admin/index.tsx b/resources/js/pages/admin/index.tsx new file mode 100644 index 000000000..6dce33cca --- /dev/null +++ b/resources/js/pages/admin/index.tsx @@ -0,0 +1,177 @@ +import { type BreadcrumbItem } from '@/types'; +import { Head, Link } from '@inertiajs/react'; +import { BarChart3, Database, Settings, Shield, Smile, Users } from 'lucide-react'; + +import Heading from '@/components/heading'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import AppLayout from '@/layouts/app-layout'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Control Panel', + href: '/admin', + }, +]; + +const adminSections = [ + { + title: 'Emoji Management', + description: 'Manage custom emojis, categories, and aliases', + icon: Smile, + href: '/admin/emojis', + color: 'bg-yellow-500', + }, + { + title: 'User Management', + description: 'Manage users, roles, and permissions', + icon: Users, + href: '/admin/users', + color: 'bg-blue-500', + disabled: true, + }, + { + title: 'System Settings', + description: 'Configure application settings and preferences', + icon: Settings, + href: '/admin/settings', + color: 'bg-gray-500', + disabled: true, + }, + { + title: 'Database Management', + description: 'Database operations and maintenance', + icon: Database, + href: '/admin/database', + color: 'bg-green-500', + disabled: true, + }, + { + title: 'Analytics', + description: 'View system analytics and reports', + icon: BarChart3, + href: '/admin/analytics', + color: 'bg-purple-500', + disabled: true, + }, + { + title: 'Security', + description: 'Security settings and audit logs', + icon: Shield, + href: '/admin/security', + color: 'bg-red-500', + disabled: true, + }, +]; + +export default function AdminIndex() { + return ( + + + +
+ + +
+ {adminSections.map((section) => { + const IconComponent = section.icon; + + return ( + + +
+
+ +
+
+ {section.title} +
+
+
+ + {section.description} + + {section.disabled ? ( + + ) : ( + + + + )} + +
+ ); + })} +
+ + {/* Quick Stats */} +
+ + + Total Users +
1,234
+
+
+ + + Active Sessions +
89
+
+
+ + + System Load +
12%
+
+
+ + + Storage Used +
2.4 GB
+
+
+
+ + {/* Recent Activity */} + + + Recent Activity + Latest administrative actions and system events + + +
+
+
+
User registration spike
+
25 new users in the last hour
+
+
2 min ago
+
+
+
+
System backup completed
+
Database backup finished successfully
+
+
1 hour ago
+
+
+
+
Security scan completed
+
No threats detected
+
+
3 hours ago
+
+
+
+
+
+
+ ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 3f73f0210..673be789b 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -14,7 +14,7 @@ export default function Dashboard() { return ( -
+
diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 1a82d8e58..27abba54d 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -28,6 +28,13 @@ export interface SharedData { auth: Auth; ziggy: Config & { location: string }; sidebarOpen: boolean; + flash?: { + success?: string; + error?: string; + warning?: string; + info?: string; + [key: string]: unknown; + }; [key: string]: unknown; } diff --git a/routes/admin.php b/routes/admin.php new file mode 100644 index 000000000..8f20d67eb --- /dev/null +++ b/routes/admin.php @@ -0,0 +1,33 @@ +group(function () { + Route::get('admin', function () { + return Inertia::render('admin/index'); + })->name('admin.index'); + + // Emoji management routes + Route::prefix('admin/emojis')->group(function () { + Route::get('/', [EmojiController::class, 'index'])->name('admin.emojis.index'); + Route::get('/create', [EmojiController::class, 'create'])->name('admin.emojis.create'); + Route::post('/', [EmojiController::class, 'store'])->name('admin.emojis.store'); + Route::get('/{emoji}/edit', [EmojiController::class, 'edit'])->name('admin.emojis.edit'); + Route::patch('/{emoji}', [EmojiController::class, 'update'])->name('admin.emojis.update'); + Route::delete('/{emoji}', [EmojiController::class, 'destroy'])->name('admin.emojis.destroy'); + + // Category management (AJAX endpoints) + Route::post('/categories', [EmojiCategoryController::class, 'store'])->name('admin.emoji-categories.store'); + Route::patch('/categories/{category}', [EmojiCategoryController::class, 'update'])->name('admin.emoji-categories.update'); + Route::delete('/categories/{category}', [EmojiCategoryController::class, 'destroy'])->name('admin.emoji-categories.destroy'); + + // Alias management (AJAX endpoints) + Route::post('/aliases', [EmojiAliasController::class, 'store'])->name('admin.emoji-aliases.store'); + Route::patch('/aliases/{alias}', [EmojiAliasController::class, 'update'])->name('admin.emoji-aliases.update'); + Route::delete('/aliases/{alias}', [EmojiAliasController::class, 'destroy'])->name('admin.emoji-aliases.destroy'); + }); +}); diff --git a/routes/web.php b/routes/web.php index 5e4cebdf6..6b8cd16b5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,4 +14,5 @@ Route::middleware(['auth', 'verified'])->group(function () { }); require __DIR__.'/settings.php'; +require __DIR__.'/admin.php'; require __DIR__.'/auth.php';