mirror of
https://github.com/torrentpier/torrentpier
synced 2025-08-22 06:13:58 -07:00
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.
This commit is contained in:
parent
18bcea02d8
commit
150c5fbbea
26 changed files with 1475 additions and 5334 deletions
|
@ -1,11 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Http\Controllers\Emoji;
|
namespace App\Http\Controllers\Admin\Emoji;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Emoji\StoreEmojiAliasRequest;
|
use App\Http\Requests\Emoji\StoreEmojiAliasRequest;
|
||||||
use App\Http\Requests\Emoji\UpdateEmojiAliasRequest;
|
use App\Http\Requests\Emoji\UpdateEmojiAliasRequest;
|
||||||
use App\Models\EmojiAlias;
|
use App\Models\EmojiAlias;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
class EmojiAliasController extends Controller
|
class EmojiAliasController extends Controller
|
||||||
{
|
{
|
||||||
|
@ -14,7 +15,7 @@ class EmojiAliasController extends Controller
|
||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
//
|
// Not needed - aliases are loaded with emojis
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -22,46 +23,52 @@ class EmojiAliasController extends Controller
|
||||||
*/
|
*/
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
//
|
// Not needed for Inertia - handled in frontend
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a newly created resource in storage.
|
* Store a newly created resource in storage.
|
||||||
*/
|
*/
|
||||||
public function store(StoreEmojiAliasRequest $request)
|
public function store(StoreEmojiAliasRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
//
|
$alias = EmojiAlias::create($request->validated());
|
||||||
|
|
||||||
|
return back()->with('success', 'Alias added successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the specified resource.
|
* Display the specified resource.
|
||||||
*/
|
*/
|
||||||
public function show(EmojiAlias $emojiAlias)
|
public function show(EmojiAlias $alias)
|
||||||
{
|
{
|
||||||
//
|
// Not needed
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the form for editing the specified resource.
|
* Show the form for editing the specified resource.
|
||||||
*/
|
*/
|
||||||
public function edit(EmojiAlias $emojiAlias)
|
public function edit(EmojiAlias $alias)
|
||||||
{
|
{
|
||||||
//
|
// Not needed for Inertia - handled in frontend
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
public function update(UpdateEmojiAliasRequest $request, EmojiAlias $emojiAlias)
|
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.
|
* Remove the specified resource from storage.
|
||||||
*/
|
*/
|
||||||
public function destroy(EmojiAlias $emojiAlias)
|
public function destroy(EmojiAlias $alias): RedirectResponse
|
||||||
{
|
{
|
||||||
//
|
$alias->delete();
|
||||||
|
|
||||||
|
return back()->with('success', 'Alias removed successfully.');
|
||||||
}
|
}
|
||||||
}
|
}
|
93
app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php
Normal file
93
app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Emoji;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Emoji\StoreEmojiCategoryRequest;
|
||||||
|
use App\Http\Requests\Emoji\UpdateEmojiCategoryRequest;
|
||||||
|
use App\Models\EmojiCategory;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EmojiCategoryController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$categories = EmojiCategory::withCount('emojis')
|
||||||
|
->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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
142
app/Http/Controllers/Admin/Emoji/EmojiController.php
Normal file
142
app/Http/Controllers/Admin/Emoji/EmojiController.php
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Emoji;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Emoji\StoreEmojiRequest;
|
||||||
|
use App\Http\Requests\Emoji\UpdateEmojiRequest;
|
||||||
|
use App\Models\Emoji;
|
||||||
|
use App\Models\EmojiCategory;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EmojiController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = Emoji::with(['category', 'aliases'])
|
||||||
|
->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.');
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,67 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Emoji;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Emoji\StoreEmojiCategoryRequest;
|
|
||||||
use App\Http\Requests\Emoji\UpdateEmojiCategoryRequest;
|
|
||||||
use App\Models\EmojiCategory;
|
|
||||||
|
|
||||||
class EmojiCategoryController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display a listing of the resource.
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for creating a new resource.
|
|
||||||
*/
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created resource in storage.
|
|
||||||
*/
|
|
||||||
public function store(StoreEmojiCategoryRequest $request)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified resource.
|
|
||||||
*/
|
|
||||||
public function show(EmojiCategory $emojiCategory)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for editing the specified resource.
|
|
||||||
*/
|
|
||||||
public function edit(EmojiCategory $emojiCategory)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the specified resource in storage.
|
|
||||||
*/
|
|
||||||
public function update(UpdateEmojiCategoryRequest $request, EmojiCategory $emojiCategory)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the specified resource from storage.
|
|
||||||
*/
|
|
||||||
public function destroy(EmojiCategory $emojiCategory)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Emoji;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Emoji\StoreEmojiRequest;
|
|
||||||
use App\Http\Requests\Emoji\UpdateEmojiRequest;
|
|
||||||
use App\Models\Emoji;
|
|
||||||
|
|
||||||
class EmojiController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Display a listing of the resource.
|
|
||||||
*/
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for creating a new resource.
|
|
||||||
*/
|
|
||||||
public function create()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a newly created resource in storage.
|
|
||||||
*/
|
|
||||||
public function store(StoreEmojiRequest $request)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Display the specified resource.
|
|
||||||
*/
|
|
||||||
public function show(Emoji $emoji)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the form for editing the specified resource.
|
|
||||||
*/
|
|
||||||
public function edit(Emoji $emoji)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update the specified resource in storage.
|
|
||||||
*/
|
|
||||||
public function update(UpdateEmojiRequest $request, Emoji $emoji)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the specified resource from storage.
|
|
||||||
*/
|
|
||||||
public function destroy(Emoji $emoji)
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -33,6 +33,7 @@ class StoreEmojiRequest extends FormRequest
|
||||||
'unique:emojis,emoji_shortcode',
|
'unique:emojis,emoji_shortcode',
|
||||||
Rule::notIn(\App\Models\EmojiAlias::pluck('alias')->toArray()),
|
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',
|
'image_url' => 'nullable|string|max:500',
|
||||||
'sprite_mode' => 'boolean',
|
'sprite_mode' => 'boolean',
|
||||||
'sprite_params' => 'nullable|array',
|
'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.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)',
|
||||||
'emoji_shortcode.unique' => 'This shortcode is already taken.',
|
'emoji_shortcode.unique' => 'This shortcode is already taken.',
|
||||||
'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.',
|
'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.',
|
||||||
|
'image.max' => 'The image must not be larger than 2MB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,7 @@ class UpdateEmojiRequest extends FormRequest
|
||||||
Rule::unique('emojis', 'emoji_shortcode')->ignore($emojiId),
|
Rule::unique('emojis', 'emoji_shortcode')->ignore($emojiId),
|
||||||
Rule::notIn(\App\Models\EmojiAlias::pluck('alias')->toArray()),
|
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',
|
'image_url' => 'nullable|string|max:500',
|
||||||
'sprite_mode' => 'sometimes|boolean',
|
'sprite_mode' => 'sometimes|boolean',
|
||||||
'sprite_params' => 'nullable|array',
|
'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.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)',
|
||||||
'emoji_shortcode.unique' => 'This shortcode is already taken.',
|
'emoji_shortcode.unique' => 'This shortcode is already taken.',
|
||||||
'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.',
|
'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.',
|
||||||
|
'image.max' => 'The image must not be larger than 2MB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
18
composer.lock
generated
18
composer.lock
generated
|
@ -7990,16 +7990,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpdoc-parser",
|
"name": "phpstan/phpdoc-parser",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
"url": "https://github.com/phpstan/phpdoc-parser.git",
|
||||||
"reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68"
|
"reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
|
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8",
|
||||||
"reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68",
|
"reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
@ -8031,9 +8031,9 @@
|
||||||
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
"description": "PHPDoc parser with support for nullable, intersection and generic types",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpstan/phpdoc-parser/issues",
|
"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",
|
"name": "phpunit/php-code-coverage",
|
||||||
|
@ -9645,12 +9645,12 @@
|
||||||
],
|
],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": "^8.4"
|
"php": "^8.4"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
Before Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
Before Width: | Height: | Size: 2.8 KiB |
File diff suppressed because it is too large
Load diff
34
package-lock.json
generated
34
package-lock.json
generated
|
@ -22,6 +22,7 @@
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "^19.0.3",
|
"@types/react": "^19.0.3",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
@ -2574,6 +2575,26 @@
|
||||||
"vite": "^5.2.0 || ^6"
|
"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": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.13.2",
|
"version": "3.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.2.tgz",
|
"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"
|
"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": {
|
"node_modules/@tanstack/virtual-core": {
|
||||||
"version": "3.13.2",
|
"version": "3.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.2.tgz",
|
||||||
|
|
|
@ -41,6 +41,7 @@
|
||||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "^19.0.3",
|
"@types/react": "^19.0.3",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.0.2",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { NavUser } from '@/components/nav-user';
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar';
|
||||||
import { type NavItem } from '@/types';
|
import { type NavItem } from '@/types';
|
||||||
import { Link } from '@inertiajs/react';
|
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';
|
import AppLogo from './app-logo';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
|
@ -26,6 +26,11 @@ const footerNavItems: NavItem[] = [
|
||||||
href: 'https://laravel.com/docs/starter-kits#react',
|
href: 'https://laravel.com/docs/starter-kits#react',
|
||||||
icon: BookOpen,
|
icon: BookOpen,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Control Panel',
|
||||||
|
href: '/admin',
|
||||||
|
icon: Settings,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar() {
|
||||||
|
|
274
resources/js/components/emoji-data-table.tsx
Normal file
274
resources/js/components/emoji-data-table.tsx
Normal file
|
@ -0,0 +1,274 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
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<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
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<SortingState>([]);
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
|
||||||
|
const columns: ColumnDef<Emoji>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'emoji_shortcode',
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
|
||||||
|
Shortcode
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => <div className="font-mono text-sm">{row.getValue('emoji_shortcode')}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: 'Title',
|
||||||
|
cell: ({ row }) => <div className="font-medium">{row.getValue('title')}</div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'emoji_text',
|
||||||
|
header: 'Display',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const emoji = row.original;
|
||||||
|
if (emoji.emoji_text) {
|
||||||
|
return <div className="text-2xl">{emoji.emoji_text}</div>;
|
||||||
|
}
|
||||||
|
if (emoji.image_url) {
|
||||||
|
return <img src={`/storage/${emoji.image_url}`} alt={emoji.emoji_shortcode} className="h-8 w-8" />;
|
||||||
|
}
|
||||||
|
return <div className="text-gray-400">N/A</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 <Badge variant={type === 'text' ? 'default' : type === 'image' ? 'secondary' : 'outline'}>{type}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'category',
|
||||||
|
header: 'Category',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const category = row.original.category;
|
||||||
|
return category ? <Badge variant="outline">{category.title}</Badge> : <span className="text-gray-400">None</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'aliases',
|
||||||
|
header: 'Aliases',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const aliases = row.original.aliases || [];
|
||||||
|
if (aliases.length === 0) {
|
||||||
|
return <span className="text-gray-400">None</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{aliases.slice(0, 2).map((alias) => (
|
||||||
|
<Badge key={alias.id} variant="secondary" className="text-xs">
|
||||||
|
{alias.alias}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{aliases.length > 2 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
+{aliases.length - 2} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}>
|
||||||
|
Created
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const date = new Date(row.getValue('created_at'));
|
||||||
|
return <div className="text-sm text-gray-500">{date.toLocaleDateString()}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const emoji = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(emoji.emoji_shortcode)}>Copy shortcode</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => router.get(route(`${routePrefix}.edit`, emoji.id))}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Are you sure you want to delete this emoji?')) {
|
||||||
|
router.delete(route(`${routePrefix}.destroy`, emoji.id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter shortcodes..."
|
||||||
|
value={(table.getColumn('emoji_shortcode')?.getFilterValue() as string) ?? ''}
|
||||||
|
onChange={(event) => table.getColumn('emoji_shortcode')?.setFilterValue(event.target.value)}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||||
|
No results.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">{table.getFilteredRowModel().rows.length} emoji(s) total.</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
114
resources/js/components/ui/table.tsx
Normal file
114
resources/js/components/ui/table.tsx
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
|
@ -22,6 +22,11 @@ const sidebarNavItems: NavItem[] = [
|
||||||
href: '/settings/appearance',
|
href: '/settings/appearance',
|
||||||
icon: null,
|
icon: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Emojis',
|
||||||
|
href: '/settings/emojis',
|
||||||
|
icon: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SettingsLayout({ children }: PropsWithChildren) {
|
export default function SettingsLayout({ children }: PropsWithChildren) {
|
||||||
|
|
393
resources/js/pages/admin/emojis/form.tsx
Normal file
393
resources/js/pages/admin/emojis/form.tsx
Normal file
|
@ -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<string, string | number | boolean>;
|
||||||
|
emoji_category_id: number | '';
|
||||||
|
display_order: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminEmojiForm({ categories, emoji }: PageProps) {
|
||||||
|
const { flash } = usePage<SharedData>().props;
|
||||||
|
const isEditing = !!emoji;
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(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<EmojiForm>({
|
||||||
|
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<string, string | number | boolean>) || {},
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<AppLayout breadcrumbs={breadcrumbs}>
|
||||||
|
<Head title={`Control Panel - ${isEditing ? 'Edit' : 'Create'} Emoji`} />
|
||||||
|
|
||||||
|
<div className="space-y-6 px-4 py-6">
|
||||||
|
{flash?.success && (
|
||||||
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-green-800">{flash.success}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<header>
|
||||||
|
<h3 className="mb-0.5 flex items-center gap-2 text-base font-medium">
|
||||||
|
<Shield className="h-5 w-5 text-blue-600" />
|
||||||
|
{isEditing ? 'Edit emoji' : 'Create emoji'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">Administrative emoji management - full control over emoji properties</p>
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-6">
|
||||||
|
{/* Basic Information */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Basic Information</CardTitle>
|
||||||
|
<CardDescription>Configure the fundamental properties of this emoji</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="title">Title *</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
value={data.title}
|
||||||
|
onChange={(e) => setData('title', e.target.value)}
|
||||||
|
placeholder="Emoji title"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError message={errors.title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="emoji_shortcode">Shortcode *</Label>
|
||||||
|
<Input
|
||||||
|
id="emoji_shortcode"
|
||||||
|
value={data.emoji_shortcode}
|
||||||
|
onChange={(e) => setData('emoji_shortcode', e.target.value)}
|
||||||
|
placeholder=":example:"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<InputError message={errors.emoji_shortcode} />
|
||||||
|
<p className="text-sm text-muted-foreground">Must be in format :name: (e.g., :smile:)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="emoji_category_id">Category</Label>
|
||||||
|
<select
|
||||||
|
id="emoji_category_id"
|
||||||
|
value={data.emoji_category_id}
|
||||||
|
onChange={(e) => setData('emoji_category_id', e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="">No category</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<InputError message={errors.emoji_category_id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="display_order">Display Order</Label>
|
||||||
|
<Input
|
||||||
|
id="display_order"
|
||||||
|
type="number"
|
||||||
|
value={data.display_order}
|
||||||
|
onChange={(e) => setData('display_order', Number(e.target.value))}
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.display_order} />
|
||||||
|
<p className="text-sm text-muted-foreground">Lower numbers appear first in lists</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Emoji Type */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Emoji Type & Content</CardTitle>
|
||||||
|
<CardDescription>Choose how this emoji will be displayed to users</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={!data.sprite_mode && !imagePreview ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleTypeChange('text')}
|
||||||
|
>
|
||||||
|
Text/Unicode
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={imagePreview && !data.sprite_mode ? 'default' : 'outline'}
|
||||||
|
onClick={() => handleTypeChange('image')}
|
||||||
|
>
|
||||||
|
Custom Image
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant={data.sprite_mode ? 'default' : 'outline'} onClick={() => handleTypeChange('sprite')}>
|
||||||
|
Sprite Sheet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!data.sprite_mode && !imagePreview && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="emoji_text">Emoji Character</Label>
|
||||||
|
<Input
|
||||||
|
id="emoji_text"
|
||||||
|
value={data.emoji_text}
|
||||||
|
onChange={(e) => setData('emoji_text', e.target.value)}
|
||||||
|
placeholder="😀"
|
||||||
|
className="text-2xl"
|
||||||
|
/>
|
||||||
|
<InputError message={errors.emoji_text} />
|
||||||
|
<p className="text-sm text-muted-foreground">Enter the Unicode emoji character</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!data.sprite_mode && (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="image">Upload Image</Label>
|
||||||
|
<div className="rounded-lg border-2 border-dashed border-gray-300 p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
{imagePreview ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<img src={imagePreview} alt="Preview" className="mx-auto h-16 w-16 object-contain" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setImagePreview(null);
|
||||||
|
setData('image', undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove image
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="image"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="image" className="cursor-pointer">
|
||||||
|
<Button type="button" variant="outline" asChild>
|
||||||
|
<span>Choose file</span>
|
||||||
|
</Button>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputError message={errors.image} />
|
||||||
|
<p className="text-sm text-muted-foreground">PNG, JPG, GIF up to 2MB. Recommended size: 32x32px</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Aliases - Only for editing */}
|
||||||
|
{isEditing && emoji?.aliases && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Aliases</CardTitle>
|
||||||
|
<CardDescription>Alternative shortcodes that users can use for this emoji</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{emoji.aliases.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{emoji.aliases.map((alias) => (
|
||||||
|
<Badge key={alias.id} variant="secondary" className="flex items-center gap-2">
|
||||||
|
{alias.alias}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeAlias(alias.id)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder=":alias:"
|
||||||
|
value={newAlias}
|
||||||
|
onChange={(e) => setNewAlias(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addAlias();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={addAlias} variant="outline">
|
||||||
|
Add alias
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="submit" disabled={processing}>
|
||||||
|
{processing ? 'Saving...' : isEditing ? 'Update emoji' : 'Create emoji'}
|
||||||
|
</Button>
|
||||||
|
<Link href={route('admin.emojis.index')}>
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
161
resources/js/pages/admin/emojis/index.tsx
Normal file
161
resources/js/pages/admin/emojis/index.tsx
Normal file
|
@ -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<SharedData>().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 (
|
||||||
|
<AppLayout breadcrumbs={breadcrumbs}>
|
||||||
|
<Head title="Control Panel - Emoji Management" />
|
||||||
|
|
||||||
|
<div className="space-y-6 px-4 py-6">
|
||||||
|
{flash?.success && (
|
||||||
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
|
<div className="text-sm font-medium text-green-800">{flash.success}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<HeadingSmall title="Emoji Management" description="Administrative emoji management - create, edit, and organize emojis" />
|
||||||
|
</div>
|
||||||
|
<Link href={route('admin.emojis.create')}>
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add emoji
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-blue-50 to-blue-100 p-4">
|
||||||
|
<div className="text-2xl font-bold text-blue-900">{emojis.total || 0}</div>
|
||||||
|
<p className="text-sm text-blue-700">Total emojis</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-green-50 to-green-100 p-4">
|
||||||
|
<div className="text-2xl font-bold text-green-900">{categories.length}</div>
|
||||||
|
<p className="text-sm text-green-700">Categories</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-purple-50 to-purple-100 p-4">
|
||||||
|
<div className="text-2xl font-bold text-purple-900">
|
||||||
|
{emojis.data?.reduce((acc, emoji) => acc + (emoji.aliases?.length || 0), 0) || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-purple-700">Total aliases</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-gradient-to-r from-orange-50 to-orange-100 p-4">
|
||||||
|
<div className="text-2xl font-bold text-orange-900">{emojis.data?.filter((emoji) => emoji.sprite_mode).length || 0}</div>
|
||||||
|
<p className="text-sm text-orange-700">Sprite emojis</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
<div className="rounded-lg border bg-gray-50 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Filters:</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => handleCategoryFilter(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All categories</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id.toString()}>
|
||||||
|
{category.title} ({category.emojis_count || 0})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data Table */}
|
||||||
|
<div className="rounded-lg border p-6">
|
||||||
|
<EmojiDataTable data={emojis.data || []} routePrefix="admin.emojis" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{emojis.links && emojis.links.length > 3 && (
|
||||||
|
<div className="flex items-center justify-center space-x-2">
|
||||||
|
{emojis.links.map((link, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={link.active ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
disabled={!link.url}
|
||||||
|
onClick={() => link.url && router.get(link.url)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
177
resources/js/pages/admin/index.tsx
Normal file
177
resources/js/pages/admin/index.tsx
Normal file
|
@ -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 (
|
||||||
|
<AppLayout breadcrumbs={breadcrumbs}>
|
||||||
|
<Head title="Control Panel" />
|
||||||
|
|
||||||
|
<div className="space-y-8 px-4 py-6">
|
||||||
|
<Heading title="Control Panel" description="Administrative tools and system management" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{adminSections.map((section) => {
|
||||||
|
const IconComponent = section.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={section.title}
|
||||||
|
className={`transition-all duration-200 hover:shadow-lg ${
|
||||||
|
section.disabled ? 'cursor-not-allowed opacity-50' : 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`rounded-lg p-2 ${section.color} text-white`}>
|
||||||
|
<IconComponent className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{section.title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<CardDescription className="mb-4">{section.description}</CardDescription>
|
||||||
|
|
||||||
|
{section.disabled ? (
|
||||||
|
<Button variant="outline" disabled className="w-full">
|
||||||
|
Coming Soon
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Link href={section.href}>
|
||||||
|
<Button className="w-full">To {section.title}</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Users</CardTitle>
|
||||||
|
<div className="text-2xl font-bold">1,234</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Active Sessions</CardTitle>
|
||||||
|
<div className="text-2xl font-bold">89</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">System Load</CardTitle>
|
||||||
|
<div className="text-2xl font-bold">12%</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Storage Used</CardTitle>
|
||||||
|
<div className="text-2xl font-bold">2.4 GB</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Recent Activity</CardTitle>
|
||||||
|
<CardDescription>Latest administrative actions and system events</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between border-b py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">User registration spike</div>
|
||||||
|
<div className="text-sm text-muted-foreground">25 new users in the last hour</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">2 min ago</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-b py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">System backup completed</div>
|
||||||
|
<div className="text-sm text-muted-foreground">Database backup finished successfully</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">1 hour ago</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Security scan completed</div>
|
||||||
|
<div className="text-sm text-muted-foreground">No threats detected</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">3 hours ago</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<AppLayout breadcrumbs={breadcrumbs}>
|
<AppLayout breadcrumbs={breadcrumbs}>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 overflow-x-auto">
|
<div className="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4">
|
||||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
||||||
|
|
7
resources/js/types/index.d.ts
vendored
7
resources/js/types/index.d.ts
vendored
|
@ -28,6 +28,13 @@ export interface SharedData {
|
||||||
auth: Auth;
|
auth: Auth;
|
||||||
ziggy: Config & { location: string };
|
ziggy: Config & { location: string };
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
flash?: {
|
||||||
|
success?: string;
|
||||||
|
error?: string;
|
||||||
|
warning?: string;
|
||||||
|
info?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
33
routes/admin.php
Normal file
33
routes/admin.php
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\Emoji\EmojiAliasController;
|
||||||
|
use App\Http\Controllers\Admin\Emoji\EmojiCategoryController;
|
||||||
|
use App\Http\Controllers\Admin\Emoji\EmojiController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
Route::middleware('auth')->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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -14,4 +14,5 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
require __DIR__.'/admin.php';
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue