diff --git a/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php b/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php new file mode 100644 index 000000000..41c9f3683 --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiAliasController.php @@ -0,0 +1,74 @@ +validated()); + + return back()->with('success', 'Alias added successfully.'); + } + + /** + * Display the specified resource. + */ + public function show(EmojiAlias $alias) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(EmojiAlias $alias) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiAliasRequest $request, EmojiAlias $alias): RedirectResponse + { + $alias->update($request->validated()); + + return back()->with('success', 'Alias updated successfully.'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(EmojiAlias $alias): RedirectResponse + { + $alias->delete(); + + return back()->with('success', 'Alias removed successfully.'); + } +} diff --git a/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php b/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php new file mode 100644 index 000000000..3bf1b62ac --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiCategoryController.php @@ -0,0 +1,93 @@ +orderBy('display_order') + ->get(); + + return response()->json($categories); + } + + /** + * Show the form for creating a new resource. + */ + public function create() + { + // Not needed for Inertia - handled in frontend + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreEmojiCategoryRequest $request): JsonResponse + { + $category = EmojiCategory::create($request->validated()); + + return response()->json([ + 'message' => 'Category created successfully.', + 'category' => $category, + ], 201); + } + + /** + * Display the specified resource. + */ + public function show(EmojiCategory $category) + { + return response()->json($category->load('emojis')); + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(EmojiCategory $category) + { + // Not needed for Inertia - handled in frontend + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiCategoryRequest $request, EmojiCategory $category): JsonResponse + { + $category->update($request->validated()); + + return response()->json([ + 'message' => 'Category updated successfully.', + 'category' => $category, + ]); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(EmojiCategory $category): JsonResponse + { + if ($category->emojis()->count() > 0) { + return response()->json([ + 'message' => 'Cannot delete category with existing emojis.', + ], 422); + } + + $category->delete(); + + return response()->json([ + 'message' => 'Category deleted successfully.', + ]); + } +} diff --git a/app/Http/Controllers/Admin/Emoji/EmojiController.php b/app/Http/Controllers/Admin/Emoji/EmojiController.php new file mode 100644 index 000000000..31eb30679 --- /dev/null +++ b/app/Http/Controllers/Admin/Emoji/EmojiController.php @@ -0,0 +1,142 @@ +orderBy('created_at', 'desc'); + + // Search functionality + if ($search = $request->get('search')) { + $query->where(function ($q) use ($search) { + $q->where('emoji_shortcode', 'like', "%{$search}%") + ->orWhere('emoji_text', 'like', "%{$search}%") + ->orWhere('title', 'like', "%{$search}%"); + }); + } + + // Category filter + if ($categoryId = $request->get('category_id')) { + $query->where('emoji_category_id', $categoryId); + } + + $emojis = $query->paginate(20)->withQueryString(); + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + + return Inertia::render('admin/emojis/index', [ + 'emojis' => $emojis, + 'categories' => $categories, + 'filters' => $request->only(['search', 'category_id']), + ]); + } + + /** + * Show the form for creating a new resource. + */ + public function create(): Response + { + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + + return Inertia::render('admin/emojis/form', [ + 'categories' => $categories, + 'emoji' => null, + ]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(StoreEmojiRequest $request): RedirectResponse + { + $data = $request->validated(); + + // Handle image upload if present + if ($request->hasFile('image')) { + $path = $request->file('image')->store('emojis', 'public'); + $data['image_url'] = $path; + } + + // Set sprite_mode based on sprite_params + if (isset($data['sprite_params']) && !empty($data['sprite_params'])) { + $data['sprite_mode'] = true; + } + + Emoji::create($data); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji created successfully.'); + } + + /** + * Display the specified resource. + */ + public function show(Emoji $emoji): Response + { + return redirect()->route('admin.emojis.edit', $emoji); + } + + /** + * Show the form for editing the specified resource. + */ + public function edit(Emoji $emoji): Response + { + $categories = EmojiCategory::withCount('emojis')->orderBy('display_order')->get(); + $emoji->load('aliases'); + + return Inertia::render('admin/emojis/form', [ + 'categories' => $categories, + 'emoji' => $emoji, + ]); + } + + /** + * Update the specified resource in storage. + */ + public function update(UpdateEmojiRequest $request, Emoji $emoji): RedirectResponse + { + $data = $request->validated(); + + // Handle image upload if present + if ($request->hasFile('image')) { + $path = $request->file('image')->store('emojis', 'public'); + $data['image_url'] = $path; + } + + // Set sprite_mode based on sprite_params + if (isset($data['sprite_params']) && !empty($data['sprite_params'])) { + $data['sprite_mode'] = true; + } + + $emoji->update($data); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji updated successfully.'); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Emoji $emoji): RedirectResponse + { + $emoji->delete(); + + return redirect()->route('admin.emojis.index') + ->with('success', 'Emoji deleted successfully.'); + } +} diff --git a/app/Http/Controllers/Emoji/EmojiAliasController.php b/app/Http/Controllers/Emoji/EmojiAliasController.php deleted file mode 100644 index 32e661349..000000000 --- a/app/Http/Controllers/Emoji/EmojiAliasController.php +++ /dev/null @@ -1,67 +0,0 @@ -toArray()), ], + 'image' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,gif,webp', 'image_url' => 'nullable|string|max:500', 'sprite_mode' => 'boolean', 'sprite_params' => 'nullable|array', @@ -55,6 +56,7 @@ class StoreEmojiRequest extends FormRequest 'emoji_shortcode.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)', 'emoji_shortcode.unique' => 'This shortcode is already taken.', 'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.', + 'image.max' => 'The image must not be larger than 2MB.', ]; } } diff --git a/app/Http/Requests/Emoji/UpdateEmojiRequest.php b/app/Http/Requests/Emoji/UpdateEmojiRequest.php index d4b0c3958..ae966bf09 100644 --- a/app/Http/Requests/Emoji/UpdateEmojiRequest.php +++ b/app/Http/Requests/Emoji/UpdateEmojiRequest.php @@ -35,6 +35,7 @@ class UpdateEmojiRequest extends FormRequest Rule::unique('emojis', 'emoji_shortcode')->ignore($emojiId), Rule::notIn(\App\Models\EmojiAlias::pluck('alias')->toArray()), ], + 'image' => 'nullable|image|max:2048|mimes:png,jpg,jpeg,gif,webp', 'image_url' => 'nullable|string|max:500', 'sprite_mode' => 'sometimes|boolean', 'sprite_params' => 'nullable|array', @@ -57,6 +58,7 @@ class UpdateEmojiRequest extends FormRequest 'emoji_shortcode.regex' => 'The emoji shortcode must be in the format :name: (e.g., :smile:)', 'emoji_shortcode.unique' => 'This shortcode is already taken.', 'emoji_shortcode.not_in' => 'This shortcode conflicts with an existing alias.', + 'image.max' => 'The image must not be larger than 2MB.', ]; } } diff --git a/composer.lock b/composer.lock index 52e46f6fc..c71c95775 100644 --- a/composer.lock +++ b/composer.lock @@ -7990,16 +7990,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.1.0", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", - "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/b9e61a61e39e02dd90944e9115241c7f7e76bfd8", + "reference": "b9e61a61e39e02dd90944e9115241c7f7e76bfd8", "shasum": "" }, "require": { @@ -8031,9 +8031,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.2.0" }, - "time": "2025-02-19T13:28:12+00:00" + "time": "2025-07-13T07:04:09+00:00" }, { "name": "phpunit/php-code-coverage", @@ -9645,12 +9645,12 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { "php": "^8.4" }, - "platform-dev": [], - "plugin-api-version": "2.3.0" + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/legacy/data/avatars/gallery/bot.gif b/legacy/data/avatars/gallery/bot.gif deleted file mode 100644 index c426414f1..000000000 Binary files a/legacy/data/avatars/gallery/bot.gif and /dev/null differ diff --git a/legacy/data/avatars/gallery/noavatar.png b/legacy/data/avatars/gallery/noavatar.png deleted file mode 100644 index acbe7c294..000000000 Binary files a/legacy/data/avatars/gallery/noavatar.png and /dev/null differ diff --git a/legacy/styles/images/ranks/admin.png b/legacy/styles/images/ranks/admin.png deleted file mode 100644 index 0f26e4177..000000000 Binary files a/legacy/styles/images/ranks/admin.png and /dev/null differ diff --git a/legacy/styles/images/ranks/user.png b/legacy/styles/images/ranks/user.png deleted file mode 100644 index 1ec271e67..000000000 Binary files a/legacy/styles/images/ranks/user.png and /dev/null differ diff --git a/legacy/styles/templates/posting_tpl.tpl b/legacy/styles/templates/posting_tpl.tpl deleted file mode 100644 index 1bd379ba2..000000000 --- a/legacy/styles/templates/posting_tpl.tpl +++ /dev/null @@ -1,5176 +0,0 @@ - - - - -

{FORUM_NAME}

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

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

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

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

+

Administrative emoji management - full control over emoji properties

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

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

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

Lower numbers appear first in lists

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

Enter the Unicode emoji character

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

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

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

Total emojis

+
+
+
{categories.length}
+

Categories

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

Total aliases

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

Sprite emojis

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