ACP: general settings, branding, and favicon uploads
@@ -53,3 +53,6 @@
|
|||||||
- Updated thread list UI (icons, spacing) and New topic button styling 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.
|
- Added ACP per-category quick-create buttons for child categories and forums.
|
||||||
- Removed the legacy navbar and cleaned up related styling.
|
- 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.
|
||||||
|
|||||||
@@ -24,4 +24,30 @@ class SettingController extends Controller
|
|||||||
|
|
||||||
return response()->json($settings);
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Http/Controllers/UploadController.php
Normal 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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,12 +36,6 @@ return new class extends Migration
|
|||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'key' => 'accent_color',
|
|
||||||
'value' => '#f29b3f',
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
39
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-data-table-component": "^7.7.0",
|
"react-data-table-component": "^7.7.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-i18next": "^16.5.0",
|
"react-i18next": "^16.5.0",
|
||||||
"react-router-dom": "^7.11.0"
|
"react-router-dom": "^7.11.0"
|
||||||
},
|
},
|
||||||
@@ -1657,6 +1658,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
@@ -2463,6 +2473,18 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/find-up": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||||
@@ -3680,6 +3702,23 @@
|
|||||||
"react": "^19.2.3"
|
"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": {
|
"node_modules/react-i18next": {
|
||||||
"version": "16.5.0",
|
"version": "16.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.0.tgz",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-data-table-component": "^7.7.0",
|
"react-data-table-component": "^7.7.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-dropzone": "^14.3.8",
|
||||||
"react-i18next": "^16.5.0",
|
"react-i18next": "^16.5.0",
|
||||||
"react-router-dom": "^7.11.0"
|
"react-router-dom": "^7.11.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import Ucp from './pages/Ucp'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { fetchSetting, fetchVersion, getForum, getThread } from './api/client'
|
import { fetchSetting, fetchVersion, getForum, getThread } from './api/client'
|
||||||
|
|
||||||
function PortalHeader({ userMenu, isAuthenticated }) {
|
function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeaderName }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [crumbs, setCrumbs] = useState([])
|
const [crumbs, setCrumbs] = useState([])
|
||||||
@@ -110,8 +110,12 @@ function PortalHeader({ userMenu, isAuthenticated }) {
|
|||||||
<Container className="pt-2 pb-2 bb-portal-shell">
|
<Container className="pt-2 pb-2 bb-portal-shell">
|
||||||
<div className="bb-portal-banner">
|
<div className="bb-portal-banner">
|
||||||
<div className="bb-portal-brand">
|
<div className="bb-portal-brand">
|
||||||
<div className="bb-portal-logo">24unix.net</div>
|
{logoUrl && (
|
||||||
<div className="bb-portal-tagline">{t('portal.tagline')}</div>
|
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
|
||||||
|
)}
|
||||||
|
{(showHeaderName || !logoUrl) && (
|
||||||
|
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-search">
|
<div className="bb-portal-search">
|
||||||
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
|
||||||
@@ -195,10 +199,27 @@ function AppShell() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { token, email, logout, isAdmin } = useAuth()
|
const { token, email, logout, isAdmin } = useAuth()
|
||||||
const [versionInfo, setVersionInfo] = useState(null)
|
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(
|
const [accentOverride, setAccentOverride] = useState(
|
||||||
() => localStorage.getItem('speedbb_accent') || ''
|
() => 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(() => {
|
useEffect(() => {
|
||||||
fetchVersion()
|
fetchVersion()
|
||||||
@@ -207,18 +228,89 @@ function AppShell() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSetting('accent_color')
|
let active = true
|
||||||
.then((setting) => {
|
const loadSettings = async () => {
|
||||||
if (setting?.value && !accentOverride) {
|
try {
|
||||||
document.documentElement.style.setProperty('--bb-accent', setting.value)
|
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 || '',
|
||||||
}
|
}
|
||||||
})
|
setSettings(next)
|
||||||
.catch(() => {})
|
} catch {
|
||||||
}, [accentOverride])
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (accentOverride) {
|
if (accentOverride) {
|
||||||
document.documentElement.style.setProperty('--bb-accent', accentOverride)
|
|
||||||
localStorage.setItem('speedbb_accent', accentOverride)
|
localStorage.setItem('speedbb_accent', accentOverride)
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('speedbb_accent')
|
localStorage.removeItem('speedbb_accent')
|
||||||
@@ -231,9 +323,12 @@ function AppShell() {
|
|||||||
|
|
||||||
const applyTheme = (mode) => {
|
const applyTheme = (mode) => {
|
||||||
if (mode === 'auto') {
|
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 {
|
} else {
|
||||||
root.setAttribute('data-bs-theme', mode)
|
root.setAttribute('data-bs-theme', mode)
|
||||||
|
setResolvedTheme(mode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +347,76 @@ function AppShell() {
|
|||||||
}
|
}
|
||||||
}, [theme])
|
}, [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 (
|
return (
|
||||||
<div className="bb-shell">
|
<div className="bb-shell">
|
||||||
<PortalHeader
|
<PortalHeader
|
||||||
isAuthenticated={!!token}
|
isAuthenticated={!!token}
|
||||||
|
forumName={settings.forumName}
|
||||||
|
logoUrl={resolvedTheme === 'dark' ? settings.logoDark : settings.logoLight}
|
||||||
|
showHeaderName={settings.showHeaderName}
|
||||||
userMenu={
|
userMenu={
|
||||||
token ? (
|
token ? (
|
||||||
<NavDropdown
|
<NavDropdown
|
||||||
|
|||||||
@@ -75,8 +75,40 @@ export async function fetchVersion() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSetting(key) {
|
export async function fetchSetting(key) {
|
||||||
const data = await getCollection(`/settings?key=${encodeURIComponent(key)}&pagination=false`)
|
const cacheBust = Date.now()
|
||||||
return data[0] || null
|
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) {
|
export async function fetchUserSetting(key) {
|
||||||
|
|||||||
@@ -216,6 +216,76 @@ a {
|
|||||||
color: #0f1218 !important;
|
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 {
|
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
|
||||||
--bs-btn-color: var(--bb-accent, #f29b3f);
|
--bs-btn-color: var(--bb-accent, #f29b3f);
|
||||||
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||||
@@ -321,6 +391,19 @@ a {
|
|||||||
font-size: 0.9rem;
|
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 {
|
.bb-topic-title a {
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-accent, #f29b3f);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -448,8 +531,9 @@ a {
|
|||||||
|
|
||||||
.bb-portal-brand {
|
.bb-portal-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-portal-logo {
|
.bb-portal-logo {
|
||||||
@@ -460,6 +544,13 @@ a {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bb-portal-logo-image {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bb-portal-tagline {
|
.bb-portal-tagline {
|
||||||
color: var(--bb-ink-muted);
|
color: var(--bb-ink-muted);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
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 DataTable, { createTheme } from 'react-data-table-component'
|
||||||
import { useTranslation } from 'react-i18next'
|
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 }) {
|
export default function Acp({ isAdmin }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -19,6 +31,25 @@ export default function Acp({ isAdmin }) {
|
|||||||
const [usersError, setUsersError] = useState('')
|
const [usersError, setUsersError] = useState('')
|
||||||
const [usersPage, setUsersPage] = useState(1)
|
const [usersPage, setUsersPage] = useState(1)
|
||||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
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(
|
const [themeMode, setThemeMode] = useState(
|
||||||
document.documentElement.getAttribute('data-bs-theme') || 'light'
|
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(() => {
|
useEffect(() => {
|
||||||
const observer = new MutationObserver(() => {
|
const observer = new MutationObserver(() => {
|
||||||
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
|
setThemeMode(document.documentElement.getAttribute('data-bs-theme') || 'light')
|
||||||
@@ -695,6 +962,296 @@ export default function Acp({ isAdmin }) {
|
|||||||
<Tabs defaultActiveKey="general" className="mb-3">
|
<Tabs defaultActiveKey="general" className="mb-3">
|
||||||
<Tab eventKey="general" title={t('acp.general')}>
|
<Tab eventKey="general" title={t('acp.general')}>
|
||||||
<p className="bb-muted">{t('acp.general_hint')}</p>
|
<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>
|
||||||
<Tab eventKey="forums" title={t('acp.forums')}>
|
<Tab eventKey="forums" title={t('acp.forums')}>
|
||||||
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
<p className="bb-muted">{t('acp.forums_hint')}</p>
|
||||||
|
|||||||
@@ -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--replies">0</div>
|
||||||
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
<div className="bb-topic-cell bb-topic-cell--views">—</div>
|
||||||
<div className="bb-topic-cell bb-topic-cell--last">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -26,6 +26,22 @@
|
|||||||
"acp.forums_name_required": "Bitte zuerst einen Namen eingeben.",
|
"acp.forums_name_required": "Bitte zuerst einen Namen eingeben.",
|
||||||
"acp.forums_parent": "Ãbergeordnete Kategorie",
|
"acp.forums_parent": "Ãbergeordnete Kategorie",
|
||||||
"acp.forums_parent_required": "Foren brauchen eine ü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_category": "Kategorie hinzufügen",
|
||||||
"acp.add_forum": "Forum hinzufügen",
|
"acp.add_forum": "Forum hinzufügen",
|
||||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||||
@@ -61,6 +77,7 @@
|
|||||||
"form.reply_placeholder": "Schreibe deine Antwort.",
|
"form.reply_placeholder": "Schreibe deine Antwort.",
|
||||||
"form.sign_in": "Anmelden",
|
"form.sign_in": "Anmelden",
|
||||||
"form.signing_in": "Anmeldung läuft...",
|
"form.signing_in": "Anmeldung läuft...",
|
||||||
|
"form.saving": "Speichern...",
|
||||||
"form.thread_body_placeholder": "Teile den Kontext und deine Frage.",
|
"form.thread_body_placeholder": "Teile den Kontext und deine Frage.",
|
||||||
"form.thread_title_placeholder": "Thema",
|
"form.thread_title_placeholder": "Thema",
|
||||||
"form.title": "Titel",
|
"form.title": "Titel",
|
||||||
|
|||||||
@@ -26,6 +26,22 @@
|
|||||||
"acp.forums_name_required": "Please enter a name before saving.",
|
"acp.forums_name_required": "Please enter a name before saving.",
|
||||||
"acp.forums_parent": "Parent category",
|
"acp.forums_parent": "Parent category",
|
||||||
"acp.forums_parent_required": "Forums must have a 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_category": "Add category",
|
||||||
"acp.add_forum": "Add forum",
|
"acp.add_forum": "Add forum",
|
||||||
"acp.forums_parent_root": "Root (no parent)",
|
"acp.forums_parent_root": "Root (no parent)",
|
||||||
@@ -61,6 +77,7 @@
|
|||||||
"form.reply_placeholder": "Share your reply.",
|
"form.reply_placeholder": "Share your reply.",
|
||||||
"form.sign_in": "Sign in",
|
"form.sign_in": "Sign in",
|
||||||
"form.signing_in": "Signing in...",
|
"form.signing_in": "Signing in...",
|
||||||
|
"form.saving": "Saving...",
|
||||||
"form.thread_body_placeholder": "Share the context and your question.",
|
"form.thread_body_placeholder": "Share the context and your question.",
|
||||||
"form.thread_title_placeholder": "Topic headline",
|
"form.thread_title_placeholder": "Topic headline",
|
||||||
"form.title": "Title",
|
"form.title": "Title",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\I18nController;
|
|||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
use App\Http\Controllers\ThreadController;
|
use App\Http\Controllers\ThreadController;
|
||||||
|
use App\Http\Controllers\UploadController;
|
||||||
use App\Http\Controllers\UserSettingController;
|
use App\Http\Controllers\UserSettingController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
use App\Http\Controllers\VersionController;
|
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('/version', VersionController::class);
|
||||||
Route::get('/settings', [SettingController::class, 'index']);
|
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::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->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('/i18n/{locale}', I18nController::class);
|
||||||
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 389 B |
|
After Width: | Height: | Size: 835 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 10 KiB |