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.
This commit is contained in:
Yury Pikhtarev 2025-07-16 21:52:20 +04:00 committed by GitHub
commit 80d6f2e921
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1522 additions and 5388 deletions

View file

@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Admin\Emoji;
use App\Http\Controllers\Controller;
use App\Http\Requests\Emoji\StoreEmojiAliasRequest;
use App\Http\Requests\Emoji\UpdateEmojiAliasRequest;
use App\Models\EmojiAlias;
use Illuminate\Http\RedirectResponse;
class EmojiAliasController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
// Not needed - aliases are loaded with emojis
}
/**
* 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(StoreEmojiAliasRequest $request): RedirectResponse
{
$alias = EmojiAlias::create($request->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.');
}
}

View 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.',
]);
}
}

View 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.');
}
}

View file

@ -1,67 +0,0 @@
<?php
namespace App\Http\Controllers\Emoji;
use App\Http\Controllers\Controller;
use App\Http\Requests\Emoji\StoreEmojiAliasRequest;
use App\Http\Requests\Emoji\UpdateEmojiAliasRequest;
use App\Models\EmojiAlias;
class EmojiAliasController 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(StoreEmojiAliasRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(EmojiAlias $emojiAlias)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(EmojiAlias $emojiAlias)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateEmojiAliasRequest $request, EmojiAlias $emojiAlias)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(EmojiAlias $emojiAlias)
{
//
}
}

View file

@ -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)
{
//
}
}

View file

@ -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)
{
//
}
}

View file

@ -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.',
]; ];
} }
} }

View file

@ -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
View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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() {

View file

@ -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<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>
);
}

View 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,
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -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" />

View file

@ -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
View 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');
});
});

View file

@ -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';