ACP: general settings, branding, and favicon uploads

This commit is contained in:
2026-01-02 20:01:22 +01:00
parent 8604cdf95d
commit fe1015bff1
23 changed files with 1025 additions and 25 deletions

View File

@@ -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.

View File

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

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class UploadController extends Controller
{
public function storeLogo(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: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),
]);
}
}

View File

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

39
package-lock.json generated
View File

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

View File

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

View File

@@ -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 }) {
<Container className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
<div className="bb-portal-logo">24unix.net</div>
<div className="bb-portal-tagline">{t('portal.tagline')}</div>
{logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)}
{(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
)}
</div>
<div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
@@ -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 (
<div className="bb-shell">
<PortalHeader
isAuthenticated={!!token}
forumName={settings.forumName}
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
showHeaderName={settings.showHeaderName}
userMenu={
token ? (
<NavDropdown

View File

@@ -75,8 +75,40 @@ export async function fetchVersion() {
}
export async function fetchSetting(key) {
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
return data[0] || null
const cacheBust = Date.now()
const data = await apiFetch(
`/settings?key=${encodeURIComponent(key)}&pagination=false&_=${cacheBust}`,
{ cache: 'no-store' }
)
if (Array.isArray(data)) {
return data[0] || null
}
return data?.['hydra:member']?.[0] || null
}
export async function saveSetting(key, value) {
return apiFetch('/settings', {
method: 'POST',
body: JSON.stringify({ key, value }),
})
}
export async function uploadLogo(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/uploads/logo', {
method: 'POST',
body,
})
}
export async function uploadFavicon(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/uploads/favicon', {
method: 'POST',
body,
})
}
export async function fetchUserSetting(key) {

View File

@@ -216,6 +216,76 @@ a {
color: #0f1218 !important;
}
.bb-acp-general {
margin-top: 1rem;
max-width: 980px;
}
.bb-dropzone {
min-height: 120px;
border-radius: 12px;
border: 1px dashed rgba(255, 255, 255, 0.16);
background: rgba(20, 25, 36, 0.4);
display: flex;
align-items: center;
justify-content: center;
padding: 0.9rem;
cursor: pointer;
transition: border-color 0.2s ease;
}
.bb-dropzone:hover {
border-color: var(--bb-accent, #f29b3f);
}
.bb-dropzone-placeholder {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
color: var(--bb-ink-muted);
}
.bb-dropzone-placeholder i {
color: var(--bb-accent, #f29b3f);
font-size: 1.1rem;
}
.bb-dropzone-preview img {
max-height: 80px;
max-width: 100%;
object-fit: contain;
}
.bb-acp-accordion .accordion-item {
background: rgba(20, 25, 36, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.bb-acp-accordion .accordion-header .accordion-button {
background: transparent;
color: var(--bb-ink);
box-shadow: none;
}
.bb-acp-accordion .accordion-button:not(.collapsed) {
color: var(--bb-accent, #f29b3f);
}
.bb-acp-accordion .accordion-button::after {
background-image: none;
background-color: var(--bb-accent, #f29b3f);
width: 1.1rem;
height: 1.1rem;
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat center / contain;
-webkit-mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e") no-repeat center / contain;
}
.bb-acp-accordion .accordion-body {
background: transparent;
}
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
--bs-btn-color: var(--bb-accent, #f29b3f);
--bs-btn-border-color: var(--bb-accent, #f29b3f);
@@ -321,6 +391,19 @@ a {
font-size: 0.9rem;
}
.bb-topic-last {
display: inline-flex;
flex-direction: column;
gap: 0.1rem;
align-items: flex-start;
font-weight: 500;
}
.bb-topic-last-by {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-topic-title a {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
@@ -448,8 +531,9 @@ a {
.bb-portal-brand {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 0.25rem;
gap: 0.9rem;
}
.bb-portal-logo {
@@ -460,6 +544,13 @@ a {
font-weight: 700;
}
.bb-portal-logo-image {
width: auto;
height: auto;
max-width: none;
max-height: none;
}
.bb-portal-tagline {
color: var(--bb-ink-muted);
font-size: 0.95rem;

View File

@@ -1,8 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
import DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next'
import { createForum, deleteForum, listAllForums, listUsers, reorderForums, updateForum } from '../api/client'
import { useDropzone } from 'react-dropzone'
import {
createForum,
deleteForum,
fetchSetting,
listAllForums,
listUsers,
reorderForums,
saveSetting,
uploadFavicon,
uploadLogo,
updateForum,
} from '../api/client'
export default function Acp({ isAdmin }) {
const { t } = useTranslation()
@@ -19,6 +31,25 @@ export default function Acp({ isAdmin }) {
const [usersError, setUsersError] = useState('')
const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10)
const [generalSaving, setGeneralSaving] = useState(false)
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: '',
})
const [themeMode, setThemeMode] = useState(
document.documentElement.getAttribute('data-bs-theme') || 'light'
)
@@ -69,6 +100,242 @@ export default function Acp({ isAdmin }) {
})
}, [])
useEffect(() => {
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 }) {
<Tabs defaultActiveKey="general" className="mb-3">
<Tab eventKey="general" title={t('acp.general')}>
<p className="bb-muted">{t('acp.general_hint')}</p>
{generalError && <p className="text-danger">{generalError}</p>}
<Form onSubmit={handleGeneralSave} className="bb-acp-general">
<Row className="g-3">
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.forum_name')}</Form.Label>
<Form.Control
type="text"
value={generalSettings.forumName}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forumName: event.target.value }))
}
/>
</Form.Group>
<Form.Group className="mt-2">
<Form.Check
type="checkbox"
id="acp-show-header-name"
label={t('acp.show_header_name')}
checked={generalSettings.showHeaderName}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
showHeaderName: event.target.checked,
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.defaultTheme}
onChange={(event) => handleDefaultThemeChange(event.target.value)}
>
<option value="auto">{t('ucp.system_default')}</option>
<option value="dark">{t('nav.theme_dark')}</option>
<option value="light">{t('nav.theme_light')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_dark')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.darkAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.darkAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
darkAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.accent_light')}</Form.Label>
<div className="d-flex align-items-center gap-2">
<Form.Control
type="text"
value={generalSettings.lightAccent}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.lightAccent || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
lightAccent: event.target.value,
}))
}
/>
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_dark')}</Form.Label>
<div
{...darkLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...darkLogoDropzone.getInputProps()} />
{generalSettings.darkLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.darkLogo} alt={t('acp.logo_dark')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.logo_light')}</Form.Label>
<div
{...lightLogoDropzone.getRootProps({
className: 'bb-dropzone',
})}
>
<input {...lightLogoDropzone.getInputProps()} />
{generalSettings.lightLogo ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.lightLogo} alt={t('acp.logo_light')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col xs={12}>
<Accordion className="bb-acp-accordion">
<Accordion.Item eventKey="favicons">
<Accordion.Header>{t('acp.favicons')}</Accordion.Header>
<Accordion.Body>
<Row className="g-3">
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_ico')}</Form.Label>
<div {...faviconIcoDropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...faviconIcoDropzone.getInputProps()} />
{generalSettings.faviconIco ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.faviconIco} alt={t('acp.favicon_ico')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_16')}</Form.Label>
<div {...favicon16Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon16Dropzone.getInputProps()} />
{generalSettings.favicon16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon16} alt={t('acp.favicon_16')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_32')}</Form.Label>
<div {...favicon32Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon32Dropzone.getInputProps()} />
{generalSettings.favicon32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon32} alt={t('acp.favicon_32')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_48')}</Form.Label>
<div {...favicon48Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon48Dropzone.getInputProps()} />
{generalSettings.favicon48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon48} alt={t('acp.favicon_48')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_64')}</Form.Label>
<div {...favicon64Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon64Dropzone.getInputProps()} />
{generalSettings.favicon64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon64} alt={t('acp.favicon_64')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_128')}</Form.Label>
<div {...favicon128Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon128Dropzone.getInputProps()} />
{generalSettings.favicon128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon128} alt={t('acp.favicon_128')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
<Col lg={4}>
<Form.Group>
<Form.Label>{t('acp.favicon_256')}</Form.Label>
<div {...favicon256Dropzone.getRootProps({ className: 'bb-dropzone' })}>
<input {...favicon256Dropzone.getInputProps()} />
{generalSettings.favicon256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon256} alt={t('acp.favicon_256')} />
</div>
) : (
<div className="bb-dropzone-placeholder">
<i className="bi bi-upload" aria-hidden="true" />
<span>{t('acp.logo_upload')}</span>
</div>
)}
</div>
</Form.Group>
</Col>
</Row>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Col>
<Col xs={12} className="d-flex justify-content-end">
<Button
type="submit"
className="bb-accent-button"
disabled={generalSaving || generalUploading}
>
{generalSaving ? t('form.saving') : t('acp.save')}
</Button>
</Col>
</Row>
</Form>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>

View File

@@ -193,7 +193,17 @@ export default function ForumView() {
<div className="bb-topic-cell bb-topic-cell--replies">0</div>
<div className="bb-topic-cell bb-topic-cell--views"></div>
<div className="bb-topic-cell bb-topic-cell--last">
<span className="bb-muted">{t('thread.no_replies')}</span>
<div className="bb-topic-last">
<span className="bb-topic-last-by">
{t('thread.by')}{' '}
<span className="bb-topic-author">
{thread.user_name || t('thread.anonymous')}
</span>
</span>
{thread.created_at && (
<span className="bb-topic-date">{thread.created_at.slice(0, 10)}</span>
)}
</div>
</div>
</div>
))}

View File

@@ -26,6 +26,22 @@
"acp.forums_name_required": "Bitte zuerst einen Namen eingeben.",
"acp.forums_parent": œbergeordnete Kategorie",
"acp.forums_parent_required": "Foren brauchen eine übergeordnete Kategorie.",
"acp.forum_name": "Forenname",
"acp.default_theme": "Standard-Design",
"acp.accent_dark": "Akzentfarbe (dunkel)",
"acp.accent_light": "Akzentfarbe (hell)",
"acp.logo_dark": "Logo (dunkel)",
"acp.logo_light": "Logo (hell)",
"acp.logo_upload": "Logo hochladen",
"acp.favicons": "Favicons",
"acp.favicon_ico": "Favicon (ICO)",
"acp.favicon_16": "Favicon 16x16",
"acp.favicon_32": "Favicon 32x32",
"acp.favicon_48": "Favicon 48x48",
"acp.favicon_64": "Favicon 64x64",
"acp.favicon_128": "Favicon 128x128",
"acp.favicon_256": "Favicon 256x256",
"acp.show_header_name": "Forenname im Header anzeigen",
"acp.add_category": "Kategorie hinzufügen",
"acp.add_forum": "Forum hinzufügen",
"acp.forums_parent_root": "Wurzel (kein Parent)",
@@ -61,6 +77,7 @@
"form.reply_placeholder": "Schreibe deine Antwort.",
"form.sign_in": "Anmelden",
"form.signing_in": "Anmeldung läuft...",
"form.saving": "Speichern...",
"form.thread_body_placeholder": "Teile den Kontext und deine Frage.",
"form.thread_title_placeholder": "Thema",
"form.title": "Titel",

View File

@@ -26,6 +26,22 @@
"acp.forums_name_required": "Please enter a name before saving.",
"acp.forums_parent": "Parent category",
"acp.forums_parent_required": "Forums must have a parent category.",
"acp.forum_name": "Forum name",
"acp.default_theme": "Default theme",
"acp.accent_dark": "Accent color (dark)",
"acp.accent_light": "Accent color (light)",
"acp.logo_dark": "Logo (dark)",
"acp.logo_light": "Logo (light)",
"acp.logo_upload": "Upload logo",
"acp.favicons": "Favicons",
"acp.favicon_ico": "Favicon (ICO)",
"acp.favicon_16": "Favicon 16x16",
"acp.favicon_32": "Favicon 32x32",
"acp.favicon_48": "Favicon 48x48",
"acp.favicon_64": "Favicon 64x64",
"acp.favicon_128": "Favicon 128x128",
"acp.favicon_256": "Favicon 256x256",
"acp.show_header_name": "Display name in header",
"acp.add_category": "Add category",
"acp.add_forum": "Add forum",
"acp.forums_parent_root": "Root (no parent)",
@@ -61,6 +77,7 @@
"form.reply_placeholder": "Share your reply.",
"form.sign_in": "Sign in",
"form.signing_in": "Signing in...",
"form.saving": "Saving...",
"form.thread_body_placeholder": "Share the context and your question.",
"form.thread_title_placeholder": "Topic headline",
"form.title": "Title",

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\I18nController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SettingController;
use App\Http\Controllers\ThreadController;
use App\Http\Controllers\UploadController;
use App\Http\Controllers\UserSettingController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController;
@@ -17,8 +18,11 @@ 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::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');
Route::post('/uploads/favicon', [UploadController::class, 'storeFavicon'])->middleware('auth:sanctum');
Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB