diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b168af..1dd47af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## 2026-01-02 +- Added ACP general settings for forum name, theme, accents, and logo (no reload required). +- Added admin-only upload endpoints and ACP UI for logos and favicons. +- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header. + +## 2025-12-30 +- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts. +- Ensured API listings and ACP forum tree omit soft-deleted records by default. +- Added thread seeding for forum test data. +- Enforced category-only roots for forums (API validation, UI, and database constraint). +- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu. +- Replaced the home page with a portal-style layout and latest posts list. +- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles. +- Persisted board index collapse state per user via user_settings (DB + API + client cache). +- Added category view rendering for subcategories in ForumView. +- Updated thread list UI (icons, spacing) and New topic button styling in ForumView. +- Added ACP per-category quick-create buttons for child categories and forums. +- Removed the legacy navbar and cleaned up related styling. + +## 2025-12-29 +- Merged the React app into the Laravel codebase under `resources/js`. +- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders. +- Serve the SPA shell via Blade with Vite-managed React assets. +- Dropped Tailwind tooling to keep the frontend Bootstrap-only. +- Replaced the default Laravel README with a forum placeholder. +- Updated ACP forum tools with accent-tinted buttons and larger action spacing. +- Defaulted the ACP forum tree to collapsed on page load. +- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation. + +## 2025-12-26 +- Replaced the Symfony backend with a fresh Laravel app in `api/`. +- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version. +- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent). +- Added Laravel i18n endpoint backed by JSON translation files. +- Updated frontend auth/register flow to work with Laravel tokens. + ## 2025-12-24 - Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes. - Updated API Platform resources, filters, migrations, and JSON format support. @@ -22,37 +58,3 @@ - Improved ACP drag-and-drop hover reordering and visual drop target feedback. - Hardened ACP access so admin tools require authentication. - Updated the home page to render the forum tree with ACP-style rows and icons. - -## 2025-12-26 -- Replaced the Symfony backend with a fresh Laravel app in `api/`. -- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version. -- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent). -- Added Laravel i18n endpoint backed by JSON translation files. -- Updated frontend auth/register flow to work with Laravel tokens. - -## 2025-12-29 -- Merged the React app into the Laravel codebase under `resources/js`. -- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders. -- Serve the SPA shell via Blade with Vite-managed React assets. -- Dropped Tailwind tooling to keep the frontend Bootstrap-only. -- Replaced the default Laravel README with a forum placeholder. -- Updated ACP forum tools with accent-tinted buttons and larger action spacing. -- Defaulted the ACP forum tree to collapsed on page load. -- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation. - -## 2025-12-30 -- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts. -- Ensured API listings and ACP forum tree omit soft-deleted records by default. -- Added thread seeding for forum test data. -- Enforced category-only roots for forums (API validation, UI, and database constraint). -- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu. -- Replaced the home page with a portal-style layout and latest posts list. -- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles. -- Persisted board index collapse state per user via user_settings (DB + API + client cache). -- Added category view rendering for subcategories in ForumView. -- Updated thread list UI (icons, spacing) and New topic button styling in ForumView. -- Added ACP per-category quick-create buttons for child categories and forums. -- Removed the legacy navbar and cleaned up related styling. -- Added ACP general settings for forum name, theme, accents, and logo (no reload required). -- Added admin-only upload endpoints and ACP UI for logos and favicons. -- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header. diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 7bf18d0..52e046a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -19,22 +19,22 @@ class CreateNewUser implements CreatesNewUsers */ public function create(array $input): User { - Validator::make($input, [ + Validator::make(data: $input, rules: [ 'name' => ['required', 'string', 'max:255'], 'email' => [ 'required', 'string', 'email', 'max:255', - Rule::unique(User::class), + Rule::unique(table: User::class), ], 'password' => $this->passwordRules(), ])->validate(); - return User::create([ + return User::create(attributes: [ 'name' => $input['name'], 'email' => $input['email'], - 'password' => Hash::make($input['password']), + 'password' => Hash::make(value: $input['password']), ]); } } diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php index 76b19d3..8f35827 100644 --- a/app/Actions/Fortify/PasswordValidationRules.php +++ b/app/Actions/Fortify/PasswordValidationRules.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Validation\Rules\Password; trait PasswordValidationRules @@ -9,7 +10,7 @@ trait PasswordValidationRules /** * Get the validation rules used to validate passwords. * - * @return array|string> + * @return array|string> */ protected function passwordRules(): array { diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 0930ddf..ba1e886 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -29,8 +29,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation ], ])->validateWithBag('updateProfileInformation'); - if ($input['email'] !== $user->email && - $user instanceof MustVerifyEmail) { + if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) { $this->updateVerifiedUser($user, $input); } else { $user->forceFill([ diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 1e1280d..90427e0 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -50,4 +50,34 @@ class SettingController extends Controller 'value' => $setting->value, ]); } + + public function bulkStore(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $data = $request->validate([ + 'settings' => ['required', 'array'], + 'settings.*.key' => ['required', 'string', 'max:191'], + 'settings.*.value' => ['nullable', 'string'], + ]); + + $updated = []; + + foreach ($data['settings'] as $entry) { + $setting = Setting::updateOrCreate( + ['key' => $entry['key']], + ['value' => $entry['value'] ?? ''] + ); + $updated[] = [ + 'id' => $setting->id, + 'key' => $setting->key, + 'value' => $setting->value, + ]; + } + + return response()->json($updated); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 3fe5d9b..6626854 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,32 +2,38 @@ namespace App\Models; +use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\DatabaseNotification; +use Illuminate\Notifications\DatabaseNotificationCollection; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Carbon; use Laravel\Sanctum\HasApiTokens; +use Laravel\Sanctum\PersonalAccessToken; /** * @property int $id * @property string $name * @property string $email - * @property \Illuminate\Support\Carbon|null $email_verified_at + * @property Carbon|null $email_verified_at * @property string $password * @property string|null $two_factor_secret * @property string|null $two_factor_recovery_codes * @property string|null $two_factor_confirmed_at * @property string|null $remember_token - * @property \Illuminate\Support\Carbon|null $created_at - * @property \Illuminate\Support\Carbon|null $updated_at - * @property-read \Illuminate\Notifications\DatabaseNotificationCollection $notifications + * @property Carbon|null $created_at + * @property Carbon|null $updated_at + * @property-read DatabaseNotificationCollection $notifications * @property-read int|null $notifications_count - * @property-read \Illuminate\Database\Eloquent\Collection $roles + * @property-read Collection $roles * @property-read int|null $roles_count - * @property-read \Illuminate\Database\Eloquent\Collection $tokens + * @property-read Collection $tokens * @property-read int|null $tokens_count - * @method static \Database\Factories\UserFactory factory($count = null, $state = []) + * @method static UserFactory factory($count = null, $state = []) * @method static \Illuminate\Database\Eloquent\Builder|User newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|User newQuery() * @method static \Illuminate\Database\Eloquent\Builder|User query() @@ -46,8 +52,10 @@ use Laravel\Sanctum\HasApiTokens; */ class User extends Authenticatable implements MustVerifyEmail { - /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable; + /** @use HasFactory */ + use HasApiTokens; + use HasFactory; + use Notifiable; /** * The attributes that are mass assignable. diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 004ced4..2443169 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -36,7 +36,7 @@ class FortifyServiceProvider extends ServiceProvider Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class); RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip()); return Limit::perMinute(5)->by($throttleKey); }); diff --git a/composer.json b/composer.json index 1d91273..6412fae 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,8 @@ "laravel/fortify": "*", "laravel/framework": "^12.0", "laravel/sanctum": "*", - "laravel/tinker": "^2.10.1" + "laravel/tinker": "^2.10.1", + "ext-pdo": "*" }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.6", @@ -20,7 +21,8 @@ "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "phpunit/phpunit": "^11.5.3" + "phpunit/phpunit": "^11.5.3", + "squizlabs/php_codesniffer": "^4.0" }, "autoload": { "psr-4": { @@ -35,6 +37,7 @@ } }, "scripts": { + "phpcs": "phpcs", "setup": [ "composer install", "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"", diff --git a/composer.lock b/composer.lock index 5e1c005..e5c2469 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5b4bf74d07107b744033916f34a83be1", + "content-hash": "8e91d3287080a532070e38f61106f41c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -8827,6 +8827,85 @@ ], "time": "2024-10-09T05:16:32+00:00" }, + { + "name": "squizlabs/php_codesniffer", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0525c73950de35ded110cffafb9892946d7771b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0525c73950de35ded110cffafb9892946d7771b5", + "reference": "0525c73950de35ded110cffafb9892946d7771b5", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=7.2.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.4.0 || ^9.3.4 || ^10.5.32 || 11.3.3 - 11.5.28 || ^11.5.31" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-10T16:43:36+00:00" + }, { "name": "staabm/side-effects-detector", "version": "1.0.5", diff --git a/config/cache.php b/config/cache.php index b32aead..7c8eea9 100644 --- a/config/cache.php +++ b/config/cache.php @@ -112,6 +112,6 @@ return [ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'), ]; diff --git a/config/database.php b/config/database.php index c57fa63..777e6e0 100644 --- a/config/database.php +++ b/config/database.php @@ -59,7 +59,9 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? + \Pdo\Mysql::ATTR_SSL_CA : + \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -79,7 +81,9 @@ return [ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + (PHP_VERSION_ID >= 80500 ? + \Pdo\Mysql::ATTR_SSL_CA : + \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], @@ -148,7 +152,7 @@ return [ 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'), 'persistent' => env('REDIS_PERSISTENT', false), ], diff --git a/config/filesystems.php b/config/filesystems.php index 3d671bd..e4326d5 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -9,7 +9,7 @@ return [ | | Here you may specify the default filesystem disk that should be used | by the framework. The "local" disk, as well as a variety of cloud - | based disks are available to your application for file storage. + | based disks, are available to your application for file storage. | */ @@ -41,7 +41,7 @@ return [ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'url' => env('APP_URL') . '/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, diff --git a/config/logging.php b/config/logging.php index 9e998a4..825366f 100644 --- a/config/logging.php +++ b/config/logging.php @@ -89,7 +89,7 @@ return [ 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], diff --git a/config/mail.php b/config/mail.php index 522b284..f9c2a43 100644 --- a/config/mail.php +++ b/config/mail.php @@ -46,7 +46,10 @@ return [ 'username' => env('MAIL_USERNAME'), 'password' => env('MAIL_PASSWORD'), 'timeout' => null, - 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + 'local_domain' => env( + 'MAIL_EHLO_DOMAIN', + parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST) + ), ], 'ses' => [ diff --git a/config/session.php b/config/session.php index 5b541b7..ac879be 100644 --- a/config/session.php +++ b/config/session.php @@ -129,7 +129,7 @@ return [ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')).'-session' + Str::slug((string) env('APP_NAME', 'laravel')) . '-session' ), /* diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..e670c17 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,18 @@ + + + Project coding standard based on PSR-12. + + + + app + config + database + routes + tests + + bootstrap/cache/* + node_modules/* + public/build/* + storage/* + vendor/* + diff --git a/resources/js/App.jsx b/resources/js/App.jsx index bbc9286..1348665 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -11,7 +11,7 @@ import Acp from './pages/Acp' import BoardIndex from './pages/BoardIndex' import Ucp from './pages/Ucp' import { useTranslation } from 'react-i18next' -import { fetchSetting, fetchVersion, getForum, getThread } from './api/client' +import { fetchSettings, fetchVersion, getForum, getThread } from './api/client' function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) { const { t } = useTranslation() @@ -231,53 +231,24 @@ function AppShell() { let active = true const loadSettings = async () => { try { - const [ - forumNameSetting, - defaultThemeSetting, - accentDarkSetting, - accentLightSetting, - logoDarkSetting, - logoLightSetting, - showHeaderNameSetting, - faviconIcoSetting, - favicon16Setting, - favicon32Setting, - favicon48Setting, - favicon64Setting, - favicon128Setting, - favicon256Setting, - ] = await Promise.all([ - fetchSetting('forum_name'), - fetchSetting('default_theme'), - fetchSetting('accent_color_dark'), - fetchSetting('accent_color_light'), - fetchSetting('logo_dark'), - fetchSetting('logo_light'), - fetchSetting('show_header_name'), - fetchSetting('favicon_ico'), - fetchSetting('favicon_16'), - fetchSetting('favicon_32'), - fetchSetting('favicon_48'), - fetchSetting('favicon_64'), - fetchSetting('favicon_128'), - fetchSetting('favicon_256'), - ]) + const allSettings = await fetchSettings() + const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value])) if (!active) return const next = { - forumName: forumNameSetting?.value || '', - defaultTheme: defaultThemeSetting?.value || 'auto', - accentDark: accentDarkSetting?.value || '', - accentLight: accentLightSetting?.value || '', - logoDark: logoDarkSetting?.value || '', - logoLight: logoLightSetting?.value || '', - showHeaderName: showHeaderNameSetting?.value !== 'false', - faviconIco: faviconIcoSetting?.value || '', - favicon16: favicon16Setting?.value || '', - favicon32: favicon32Setting?.value || '', - favicon48: favicon48Setting?.value || '', - favicon64: favicon64Setting?.value || '', - favicon128: favicon128Setting?.value || '', - favicon256: favicon256Setting?.value || '', + forumName: settingsMap.get('forum_name') || '', + defaultTheme: settingsMap.get('default_theme') || 'auto', + accentDark: settingsMap.get('accent_color_dark') || '', + accentLight: settingsMap.get('accent_color_light') || '', + logoDark: settingsMap.get('logo_dark') || '', + logoLight: settingsMap.get('logo_light') || '', + showHeaderName: settingsMap.get('show_header_name') !== 'false', + faviconIco: settingsMap.get('favicon_ico') || '', + favicon16: settingsMap.get('favicon_16') || '', + favicon32: settingsMap.get('favicon_32') || '', + favicon48: settingsMap.get('favicon_48') || '', + favicon64: settingsMap.get('favicon_64') || '', + favicon128: settingsMap.get('favicon_128') || '', + favicon256: settingsMap.get('favicon_256') || '', } setSettings(next) } catch { diff --git a/resources/js/api/client.js b/resources/js/api/client.js index 4ab47ee..ecf06ab 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -75,6 +75,7 @@ export async function fetchVersion() { } export async function fetchSetting(key) { + // TODO: Prefer fetchSettings() when multiple settings are needed. const cacheBust = Date.now() const data = await apiFetch( `/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`, @@ -86,6 +87,15 @@ export async function fetchSetting(key) { return data?.['hydra:member']?.[0] || null } +export async function fetchSettings() { + const cacheBust = Date.now() + const data = await apiFetch(`/settings?pagination=false&_=${cacheBust}`, { cache: 'no-store' }) + if (Array.isArray(data)) { + return data + } + return data?.['hydra:member'] || [] +} + export async function saveSetting(key, value) { return apiFetch('/settings', { method: 'POST', @@ -93,6 +103,13 @@ export async function saveSetting(key, value) { }) } +export async function saveSettings(settings) { + return apiFetch('/settings/bulk', { + method: 'POST', + body: JSON.stringify({ settings }), + }) +} + export async function uploadLogo(file) { const body = new FormData() body.append('file', file) diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index 7a28802..9cbcbc4 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -6,11 +6,12 @@ import { useDropzone } from 'react-dropzone' import { createForum, deleteForum, - fetchSetting, + fetchSettings, listAllForums, listUsers, reorderForums, saveSetting, + saveSettings, uploadFavicon, uploadLogo, updateForum, @@ -35,21 +36,37 @@ export default function Acp({ isAdmin }) { const [generalUploading, setGeneralUploading] = useState(false) const [generalError, setGeneralError] = useState('') const [generalSettings, setGeneralSettings] = useState({ - forumName: '', - defaultTheme: 'auto', - darkAccent: '', - lightAccent: '', - darkLogo: '', - lightLogo: '', - showHeaderName: true, - faviconIco: '', - favicon16: '', - favicon32: '', - favicon48: '', - favicon64: '', - favicon128: '', - favicon256: '', + forum_name: '', + default_theme: 'auto', + accent_color_dark: '', + accent_color_light: '', + logo_dark: '', + logo_light: '', + show_header_name: 'true', + favicon_ico: '', + favicon_16: '', + favicon_32: '', + favicon_48: '', + favicon_64: '', + favicon_128: '', + favicon_256: '', }) + const settingsDetailMap = { + forum_name: 'forumName', + default_theme: 'defaultTheme', + accent_color_dark: 'accentDark', + accent_color_light: 'accentLight', + logo_dark: 'logoDark', + logo_light: 'logoLight', + show_header_name: 'showHeaderName', + favicon_ico: 'faviconIco', + favicon_16: 'favicon16', + favicon_32: 'favicon32', + favicon_48: 'favicon48', + favicon_64: 'favicon64', + favicon_128: 'favicon128', + favicon_256: 'favicon256', + } const [themeMode, setThemeMode] = useState( document.documentElement.getAttribute('data-bs-theme') || 'light' ) @@ -105,39 +122,24 @@ export default function Acp({ isAdmin }) { let active = true const loadSettings = async () => { try { - const keys = [ - 'forum_name', - 'default_theme', - 'accent_color_dark', - 'accent_color_light', - 'logo_dark', - 'logo_light', - 'show_header_name', - 'favicon_ico', - 'favicon_16', - 'favicon_32', - 'favicon_48', - 'favicon_64', - 'favicon_128', - 'favicon_256', - ] - const results = await Promise.all(keys.map((key) => fetchSetting(key))) + const allSettings = await fetchSettings() + const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value])) if (!active) return const next = { - forumName: results[0]?.value || '', - defaultTheme: results[1]?.value || 'auto', - darkAccent: results[2]?.value || '', - lightAccent: results[3]?.value || '', - darkLogo: results[4]?.value || '', - lightLogo: results[5]?.value || '', - showHeaderName: results[6]?.value !== 'false', - faviconIco: results[7]?.value || '', - favicon16: results[8]?.value || '', - favicon32: results[9]?.value || '', - favicon48: results[10]?.value || '', - favicon64: results[11]?.value || '', - favicon128: results[12]?.value || '', - favicon256: results[13]?.value || '', + forum_name: settingsMap.get('forum_name') || '', + default_theme: settingsMap.get('default_theme') || 'auto', + accent_color_dark: settingsMap.get('accent_color_dark') || '', + accent_color_light: settingsMap.get('accent_color_light') || '', + logo_dark: settingsMap.get('logo_dark') || '', + logo_light: settingsMap.get('logo_light') || '', + show_header_name: settingsMap.get('show_header_name') || 'true', + favicon_ico: settingsMap.get('favicon_ico') || '', + favicon_16: settingsMap.get('favicon_16') || '', + favicon_32: settingsMap.get('favicon_32') || '', + favicon_48: settingsMap.get('favicon_48') || '', + favicon_64: settingsMap.get('favicon_64') || '', + favicon_128: settingsMap.get('favicon_128') || '', + favicon_256: settingsMap.get('favicon_256') || '', } setGeneralSettings(next) } catch (err) { @@ -155,42 +157,23 @@ export default function Acp({ isAdmin }) { setGeneralSaving(true) setGeneralError('') try { - await Promise.all([ - saveSetting('forum_name', generalSettings.forumName.trim() || ''), - saveSetting('default_theme', generalSettings.defaultTheme || 'auto'), - saveSetting('accent_color_dark', generalSettings.darkAccent.trim() || ''), - saveSetting('accent_color_light', generalSettings.lightAccent.trim() || ''), - saveSetting('logo_dark', generalSettings.darkLogo.trim() || ''), - saveSetting('logo_light', generalSettings.lightLogo.trim() || ''), - saveSetting('show_header_name', generalSettings.showHeaderName ? 'true' : 'false'), - saveSetting('favicon_ico', generalSettings.faviconIco.trim() || ''), - saveSetting('favicon_16', generalSettings.favicon16.trim() || ''), - saveSetting('favicon_32', generalSettings.favicon32.trim() || ''), - saveSetting('favicon_48', generalSettings.favicon48.trim() || ''), - saveSetting('favicon_64', generalSettings.favicon64.trim() || ''), - saveSetting('favicon_128', generalSettings.favicon128.trim() || ''), - saveSetting('favicon_256', generalSettings.favicon256.trim() || ''), - ]) - window.dispatchEvent( - new CustomEvent('speedbb-settings-updated', { - detail: { - forumName: generalSettings.forumName.trim() || '', - defaultTheme: generalSettings.defaultTheme || 'auto', - accentDark: generalSettings.darkAccent.trim() || '', - accentLight: generalSettings.lightAccent.trim() || '', - logoDark: generalSettings.darkLogo.trim() || '', - logoLight: generalSettings.lightLogo.trim() || '', - showHeaderName: generalSettings.showHeaderName, - faviconIco: generalSettings.faviconIco.trim() || '', - favicon16: generalSettings.favicon16.trim() || '', - favicon32: generalSettings.favicon32.trim() || '', - favicon48: generalSettings.favicon48.trim() || '', - favicon64: generalSettings.favicon64.trim() || '', - favicon128: generalSettings.favicon128.trim() || '', - favicon256: generalSettings.favicon256.trim() || '', - }, - }) + await saveSettings( + Object.entries(generalSettings).map(([key, value]) => ({ + key, + value: typeof value === 'string' ? value.trim() : String(value ?? ''), + })) ) + const detail = Object.entries(generalSettings).reduce((acc, [key, value]) => { + const mappedKey = settingsDetailMap[key] + if (!mappedKey) return acc + if (key === 'show_header_name') { + acc[mappedKey] = value !== 'false' + return acc + } + acc[mappedKey] = typeof value === 'string' ? value.trim() : String(value ?? '') + return acc + }, {}) + window.dispatchEvent(new CustomEvent('speedbb-settings-updated', { detail })) } catch (err) { setGeneralError(err.message) } finally { @@ -199,8 +182,8 @@ export default function Acp({ isAdmin }) { } const handleDefaultThemeChange = async (value) => { - const previous = generalSettings.defaultTheme - setGeneralSettings((prev) => ({ ...prev, defaultTheme: value })) + const previous = generalSettings.default_theme + setGeneralSettings((prev) => ({ ...prev, default_theme: value })) setGeneralError('') try { await saveSetting('default_theme', value) @@ -210,22 +193,29 @@ export default function Acp({ isAdmin }) { }) ) } catch (err) { - setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous })) + setGeneralSettings((prev) => ({ ...prev, default_theme: previous })) setGeneralError(err.message) } } - const handleLogoUpload = async (file, variantKey) => { + const handleLogoUpload = async (file, settingKey) => { if (!file) return setGeneralUploading(true) setGeneralError('') try { const result = await uploadLogo(file) const url = result?.url || '' - const settingKey = variantKey === 'darkLogo' ? 'logo_dark' : 'logo_light' - setGeneralSettings((prev) => ({ ...prev, [variantKey]: url })) + setGeneralSettings((prev) => ({ ...prev, [settingKey]: url })) if (url) { await saveSetting(settingKey, url) + const mappedKey = settingsDetailMap[settingKey] + if (mappedKey) { + window.dispatchEvent( + new CustomEvent('speedbb-settings-updated', { + detail: { [mappedKey]: url }, + }) + ) + } } } catch (err) { setGeneralError(err.message) @@ -234,21 +224,24 @@ export default function Acp({ isAdmin }) { } } - const handleFaviconUpload = async (file, settingKey, stateKey) => { + const handleFaviconUpload = async (file, settingKey) => { if (!file) return setGeneralUploading(true) setGeneralError('') try { const result = await uploadFavicon(file) const url = result?.url || '' - setGeneralSettings((prev) => ({ ...prev, [stateKey]: url })) + setGeneralSettings((prev) => ({ ...prev, [settingKey]: url })) if (url) { await saveSetting(settingKey, url) - window.dispatchEvent( - new CustomEvent('speedbb-settings-updated', { - detail: { [stateKey]: url }, - }) - ) + const mappedKey = settingsDetailMap[settingKey] + if (mappedKey) { + window.dispatchEvent( + new CustomEvent('speedbb-settings-updated', { + detail: { [mappedKey]: url }, + }) + ) + } } } catch (err) { setGeneralError(err.message) @@ -263,7 +256,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico', 'faviconIco'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico'), }) const favicon16Dropzone = useDropzone({ @@ -272,7 +265,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16'), }) const favicon32Dropzone = useDropzone({ @@ -281,7 +274,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32'), }) const favicon48Dropzone = useDropzone({ @@ -290,7 +283,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48'), }) const favicon64Dropzone = useDropzone({ @@ -299,7 +292,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64'), }) const favicon128Dropzone = useDropzone({ @@ -308,7 +301,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128'), }) const favicon256Dropzone = useDropzone({ @@ -317,7 +310,7 @@ export default function Acp({ isAdmin }) { 'image/x-icon': ['.ico'], }, maxFiles: 1, - onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'), + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256'), }) const darkLogoDropzone = useDropzone({ @@ -325,7 +318,7 @@ export default function Acp({ isAdmin }) { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], }, maxFiles: 1, - onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'), + onDrop: (files) => handleLogoUpload(files[0], 'logo_dark'), }) const lightLogoDropzone = useDropzone({ @@ -333,7 +326,7 @@ export default function Acp({ isAdmin }) { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], }, maxFiles: 1, - onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'), + onDrop: (files) => handleLogoUpload(files[0], 'logo_light'), }) useEffect(() => { @@ -970,9 +963,9 @@ export default function Acp({ isAdmin }) { {t('acp.forum_name')} - setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value })) + setGeneralSettings((prev) => ({ ...prev, forum_name: event.target.value })) } /> @@ -981,11 +974,11 @@ export default function Acp({ isAdmin }) { type="checkbox" id="acp-show-header-name" label={t('acp.show_header_name')} - checked={generalSettings.showHeaderName} + checked={generalSettings.show_header_name !== 'false'} onChange={(event) => setGeneralSettings((prev) => ({ ...prev, - showHeaderName: event.target.checked, + show_header_name: event.target.checked ? 'true' : 'false', })) } /> @@ -995,7 +988,7 @@ export default function Acp({ isAdmin }) { {t('acp.default_theme')} handleDefaultThemeChange(event.target.value)} > @@ -1010,22 +1003,22 @@ export default function Acp({ isAdmin }) {
setGeneralSettings((prev) => ({ ...prev, - darkAccent: event.target.value, + accent_color_dark: event.target.value, })) } placeholder="#f29b3f" /> setGeneralSettings((prev) => ({ ...prev, - darkAccent: event.target.value, + accent_color_dark: event.target.value, })) } /> @@ -1038,22 +1031,22 @@ export default function Acp({ isAdmin }) {
setGeneralSettings((prev) => ({ ...prev, - lightAccent: event.target.value, + accent_color_light: event.target.value, })) } placeholder="#f29b3f" /> setGeneralSettings((prev) => ({ ...prev, - lightAccent: event.target.value, + accent_color_light: event.target.value, })) } /> @@ -1069,9 +1062,9 @@ export default function Acp({ isAdmin }) { })} > - {generalSettings.darkLogo ? ( + {generalSettings.logo_dark ? (
- {t('acp.logo_dark')} + {t('acp.logo_dark')}
) : (
@@ -1091,9 +1084,9 @@ export default function Acp({ isAdmin }) { })} > - {generalSettings.lightLogo ? ( + {generalSettings.logo_light ? (
- {t('acp.logo_light')} + {t('acp.logo_light')}
) : (
@@ -1115,9 +1108,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_ico')}
- {generalSettings.faviconIco ? ( + {generalSettings.favicon_ico ? (
- {t('acp.favicon_ico')} + {t('acp.favicon_ico')}
) : (
@@ -1133,9 +1126,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_16')}
- {generalSettings.favicon16 ? ( + {generalSettings.favicon_16 ? (
- {t('acp.favicon_16')} + {t('acp.favicon_16')}
) : (
@@ -1151,9 +1144,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_32')}
- {generalSettings.favicon32 ? ( + {generalSettings.favicon_32 ? (
- {t('acp.favicon_32')} + {t('acp.favicon_32')}
) : (
@@ -1169,9 +1162,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_48')}
- {generalSettings.favicon48 ? ( + {generalSettings.favicon_48 ? (
- {t('acp.favicon_48')} + {t('acp.favicon_48')}
) : (
@@ -1187,9 +1180,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_64')}
- {generalSettings.favicon64 ? ( + {generalSettings.favicon_64 ? (
- {t('acp.favicon_64')} + {t('acp.favicon_64')}
) : (
@@ -1205,9 +1198,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_128')}
- {generalSettings.favicon128 ? ( + {generalSettings.favicon_128 ? (
- {t('acp.favicon_128')} + {t('acp.favicon_128')}
) : (
@@ -1223,9 +1216,9 @@ export default function Acp({ isAdmin }) { {t('acp.favicon_256')}
- {generalSettings.favicon256 ? ( + {generalSettings.favicon_256 ? (
- {t('acp.favicon_256')} + {t('acp.favicon_256')}
) : (
diff --git a/routes/api.php b/routes/api.php index 29303e1..25a5753 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,6 +19,7 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc Route::get('/version', VersionController::class); Route::get('/settings', [SettingController::class, 'index']); Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum'); +Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum'); Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum'); Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum'); Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum'); diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -}