ACP: general settings, branding, and favicon uploads
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
'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-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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
|
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 |