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:
Yury Pikhtarev 2025-07-16 21:33:33 +04:00
commit 150c5fbbea
No known key found for this signature in database
26 changed files with 1475 additions and 5334 deletions

View file

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

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\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,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>
);
}

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

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

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