diff --git a/CHANGELOG.md b/CHANGELOG.md index 48251c8..1b168af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,3 +53,6 @@ - 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/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 6a6c057..1e1280d 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -24,4 +24,30 @@ class SettingController extends Controller return response()->json($settings); } + + public function store(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $data = $request->validate([ + 'key' => ['required', 'string', 'max:191'], + 'value' => ['nullable', 'string'], + ]); + + $value = $data['value'] ?? ''; + + $setting = Setting::updateOrCreate( + ['key' => $data['key']], + ['value' => $value] + ); + + return response()->json([ + 'id' => $setting->id, + 'key' => $setting->key, + 'value' => $setting->value, + ]); + } } diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php new file mode 100644 index 0000000..6be801f --- /dev/null +++ b/app/Http/Controllers/UploadController.php @@ -0,0 +1,48 @@ +user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $data = $request->validate([ + 'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'], + ]); + + $path = $data['file']->store('logos', 'public'); + + return response()->json([ + 'path' => $path, + 'url' => Storage::url($path), + ]); + } + + public function storeFavicon(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $data = $request->validate([ + 'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'], + ]); + + $path = $data['file']->store('favicons', 'public'); + + return response()->json([ + 'path' => $path, + 'url' => Storage::url($path), + ]); + } +} diff --git a/database/migrations/2025_12_26_161933_create_settings_table.php b/database/migrations/2025_12_26_161933_create_settings_table.php index 935e69a..5b0eb71 100644 --- a/database/migrations/2025_12_26_161933_create_settings_table.php +++ b/database/migrations/2025_12_26_161933_create_settings_table.php @@ -36,12 +36,6 @@ return new class extends Migration 'created_at' => now(), 'updated_at' => now(), ], - [ - 'key' => 'accent_color', - 'value' => '#f29b3f', - 'created_at' => now(), - 'updated_at' => now(), - ], ]); } diff --git a/package-lock.json b/package-lock.json index b8c424f..04e067c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "react-bootstrap": "^2.10.10", "react-data-table-component": "^7.7.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-i18next": "^16.5.0", "react-router-dom": "^7.11.0" }, @@ -1657,6 +1658,15 @@ "dev": true, "license": "MIT" }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -2463,6 +2473,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -3680,6 +3702,23 @@ "react": "^19.2.3" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-i18next": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz", diff --git a/package.json b/package.json index 2f64e28..883b26f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "react-bootstrap": "^2.10.10", "react-data-table-component": "^7.7.0", "react-dom": "^19.2.0", + "react-dropzone": "^14.3.8", "react-i18next": "^16.5.0", "react-router-dom": "^7.11.0" }, diff --git a/resources/js/App.jsx b/resources/js/App.jsx index 5b5c1ab..bbc9286 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -13,7 +13,7 @@ import Ucp from './pages/Ucp' import { useTranslation } from 'react-i18next' import { fetchSetting, fetchVersion, getForum, getThread } from './api/client' -function PortalHeader({ userMenu, isAuthenticated }) { +function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) { const { t } = useTranslation() const location = useLocation() const [crumbs, setCrumbs] = useState([]) @@ -110,8 +110,12 @@ function PortalHeader({ userMenu, isAuthenticated }) {
-
24unix.net
-
{t('portal.tagline')}
+ {logoUrl && ( + {forumName + )} + {(showHeaderName || !logoUrl) && ( +
{forumName || '24unix.net'}
+ )}
@@ -195,10 +199,27 @@ function AppShell() { const { t } = useTranslation() const { token, email, logout, isAdmin } = useAuth() const [versionInfo, setVersionInfo] = useState(null) - const [theme, setTheme] = useState(() => localStorage.getItem('speedbb_theme') || 'auto') + const [theme, setTheme] = useState('auto') + const [resolvedTheme, setResolvedTheme] = useState('light') const [accentOverride, setAccentOverride] = useState( () => localStorage.getItem('speedbb_accent') || '' ) + const [settings, setSettings] = useState({ + forumName: '', + defaultTheme: 'auto', + accentDark: '', + accentLight: '', + logoDark: '', + logoLight: '', + showHeaderName: true, + faviconIco: '', + favicon16: '', + favicon32: '', + favicon48: '', + favicon64: '', + favicon128: '', + favicon256: '', + }) useEffect(() => { fetchVersion() @@ -207,18 +228,89 @@ function AppShell() { }, []) useEffect(() => { - fetchSetting('accent_color') - .then((setting) => { - if (setting?.value && !accentOverride) { - document.documentElement.style.setProperty('--bb-accent', setting.value) + 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'), + ]) + 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 || '', } - }) - .catch(() => {}) - }, [accentOverride]) + setSettings(next) + } catch { + // keep defaults + } + } + loadSettings() + return () => { + active = false + } + }, []) + + useEffect(() => { + const stored = token ? localStorage.getItem('speedbb_theme') : null + const nextTheme = stored || settings.defaultTheme || 'auto' + setTheme(nextTheme) + }, [token, settings.defaultTheme]) + + useEffect(() => { + const handleSettingsUpdate = (event) => { + const next = event.detail + if (!next) return + setSettings((prev) => ({ ...prev, ...next })) + } + + window.addEventListener('speedbb-settings-updated', handleSettingsUpdate) + return () => { + window.removeEventListener('speedbb-settings-updated', handleSettingsUpdate) + } + }, []) useEffect(() => { if (accentOverride) { - document.documentElement.style.setProperty('--bb-accent', accentOverride) localStorage.setItem('speedbb_accent', accentOverride) } else { localStorage.removeItem('speedbb_accent') @@ -231,9 +323,12 @@ function AppShell() { const applyTheme = (mode) => { if (mode === 'auto') { - root.setAttribute('data-bs-theme', media.matches ? 'dark' : 'light') + const next = media.matches ? 'dark' : 'light' + root.setAttribute('data-bs-theme', next) + setResolvedTheme(next) } else { root.setAttribute('data-bs-theme', mode) + setResolvedTheme(mode) } } @@ -252,10 +347,76 @@ function AppShell() { } }, [theme]) + useEffect(() => { + const accent = + accentOverride || + (resolvedTheme === 'dark' ? settings.accentDark : settings.accentLight) || + settings.accentDark || + settings.accentLight + if (accent) { + document.documentElement.style.setProperty('--bb-accent', accent) + } + }, [accentOverride, resolvedTheme, settings]) + + useEffect(() => { + if (settings.forumName) { + document.title = settings.forumName + } + }, [settings.forumName]) + + useEffect(() => { + const upsertIcon = (id, rel, href, sizes, type) => { + if (!href) { + const existing = document.getElementById(id) + if (existing) { + existing.remove() + } + return + } + let link = document.getElementById(id) + if (!link) { + link = document.createElement('link') + link.id = id + document.head.appendChild(link) + } + link.setAttribute('rel', rel) + link.setAttribute('href', href) + if (sizes) { + link.setAttribute('sizes', sizes) + } else { + link.removeAttribute('sizes') + } + if (type) { + link.setAttribute('type', type) + } else { + link.removeAttribute('type') + } + } + + upsertIcon('favicon-ico', 'icon', settings.faviconIco, null, 'image/x-icon') + upsertIcon('favicon-16', 'icon', settings.favicon16, '16x16', 'image/png') + upsertIcon('favicon-32', 'icon', settings.favicon32, '32x32', 'image/png') + upsertIcon('favicon-48', 'icon', settings.favicon48, '48x48', 'image/png') + upsertIcon('favicon-64', 'icon', settings.favicon64, '64x64', 'image/png') + upsertIcon('favicon-128', 'icon', settings.favicon128, '128x128', 'image/png') + upsertIcon('favicon-256', 'icon', settings.favicon256, '256x256', 'image/png') + }, [ + settings.faviconIco, + settings.favicon16, + settings.favicon32, + settings.favicon48, + settings.favicon64, + settings.favicon128, + settings.favicon256, + ]) + return (
{ + if (!isAdmin) return + 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))) + 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 || '', + } + setGeneralSettings(next) + } catch (err) { + if (active) setGeneralError(err.message) + } + } + loadSettings() + return () => { + active = false + } + }, [isAdmin]) + + const handleGeneralSave = async (event) => { + event.preventDefault() + 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() || '', + }, + }) + ) + } catch (err) { + setGeneralError(err.message) + } finally { + setGeneralSaving(false) + } + } + + const handleDefaultThemeChange = async (value) => { + const previous = generalSettings.defaultTheme + setGeneralSettings((prev) => ({ ...prev, defaultTheme: value })) + setGeneralError('') + try { + await saveSetting('default_theme', value) + window.dispatchEvent( + new CustomEvent('speedbb-settings-updated', { + detail: { defaultTheme: value }, + }) + ) + } catch (err) { + setGeneralSettings((prev) => ({ ...prev, defaultTheme: previous })) + setGeneralError(err.message) + } + } + + const handleLogoUpload = async (file, variantKey) => { + 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 })) + if (url) { + await saveSetting(settingKey, url) + } + } catch (err) { + setGeneralError(err.message) + } finally { + setGeneralUploading(false) + } + } + + const handleFaviconUpload = async (file, settingKey, stateKey) => { + if (!file) return + setGeneralUploading(true) + setGeneralError('') + try { + const result = await uploadFavicon(file) + const url = result?.url || '' + setGeneralSettings((prev) => ({ ...prev, [stateKey]: url })) + if (url) { + await saveSetting(settingKey, url) + window.dispatchEvent( + new CustomEvent('speedbb-settings-updated', { + detail: { [stateKey]: url }, + }) + ) + } + } catch (err) { + setGeneralError(err.message) + } finally { + setGeneralUploading(false) + } + } + + const faviconIcoDropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_ico', 'faviconIco'), + }) + + const favicon16Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_16', 'favicon16'), + }) + + const favicon32Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_32', 'favicon32'), + }) + + const favicon48Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_48', 'favicon48'), + }) + + const favicon64Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_64', 'favicon64'), + }) + + const favicon128Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_128', 'favicon128'), + }) + + const favicon256Dropzone = useDropzone({ + accept: { + 'image/png': ['.png'], + 'image/x-icon': ['.ico'], + }, + maxFiles: 1, + onDrop: (files) => handleFaviconUpload(files[0], 'favicon_256', 'favicon256'), + }) + + const darkLogoDropzone = useDropzone({ + accept: { + 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], + }, + maxFiles: 1, + onDrop: (files) => handleLogoUpload(files[0], 'darkLogo'), + }) + + const lightLogoDropzone = useDropzone({ + accept: { + 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'], + }, + maxFiles: 1, + onDrop: (files) => handleLogoUpload(files[0], 'lightLogo'), + }) + useEffect(() => { const observer = new MutationObserver(() => { setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light') @@ -695,6 +962,296 @@ export default function Acp({ isAdmin }) {

{t('acp.general_hint')}

+ {generalError &&

{generalError}

} +
+ + + + {t('acp.forum_name')} + + setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value })) + } + /> + + + + setGeneralSettings((prev) => ({ + ...prev, + showHeaderName: event.target.checked, + })) + } + /> + + + + + {t('acp.default_theme')} + handleDefaultThemeChange(event.target.value)} + > + + + + + + + + + {t('acp.accent_dark')} +
+ + setGeneralSettings((prev) => ({ + ...prev, + darkAccent: event.target.value, + })) + } + placeholder="#f29b3f" + /> + + setGeneralSettings((prev) => ({ + ...prev, + darkAccent: event.target.value, + })) + } + /> +
+
+ + + + {t('acp.accent_light')} +
+ + setGeneralSettings((prev) => ({ + ...prev, + lightAccent: event.target.value, + })) + } + placeholder="#f29b3f" + /> + + setGeneralSettings((prev) => ({ + ...prev, + lightAccent: event.target.value, + })) + } + /> +
+
+ + + + {t('acp.logo_dark')} +
+ + {generalSettings.darkLogo ? ( +
+ {t('acp.logo_dark')} +
+ ) : ( +
+