Add attachment thumbnails and ACP refinements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s

This commit is contained in:
2026-01-31 12:02:54 +01:00
parent 7fbc566129
commit 64244567c0
12 changed files with 933 additions and 664 deletions

View File

@@ -2176,7 +2176,57 @@ a {
}
.bb-acp {
max-width: 1880px;
max-width: 100%;
}
.bb-acp-sidebar {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bb-acp-sidebar-section {
background: rgba(16, 20, 30, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
padding: 0.75rem;
}
.bb-acp-sidebar-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bb-ink-muted);
margin-bottom: 0.5rem;
}
.bb-acp-sidebar .list-group-item {
background: transparent;
color: var(--bb-ink);
border: 0;
padding: 0.35rem 0.25rem;
}
.bb-acp-sidebar .list-group-item.is-active,
.bb-acp-sidebar .list-group-item:hover {
color: var(--bb-accent, #f29b3f);
}
.bb-acp-panel {
background: rgba(18, 23, 33, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
}
.bb-acp-panel-header {
padding: 0.9rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-acp-panel-body {
padding: 1rem;
}
.bb-icon {
@@ -2287,30 +2337,29 @@ a {
}
.bb-attachment-extension-table {
display: flex;
flex-direction: column;
gap: 0.4rem;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
background: rgba(18, 23, 33, 0.8);
}
.bb-attachment-extension-header,
.bb-attachment-extension-row {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(200px, 1.2fr) minmax(180px, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.5rem 0.8rem;
border-radius: 10px;
.bb-attachment-extension-table table {
margin-bottom: 0;
color: var(--bb-ink);
}
.bb-attachment-extension-header {
.bb-attachment-extension-table thead th {
font-size: 0.8rem;
color: var(--bb-ink-muted);
background: rgba(15, 19, 27, 0.7);
border-bottom: 0;
padding: 0.55rem 0.8rem;
}
.bb-attachment-extension-row {
background: rgba(18, 23, 33, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
.bb-attachment-extension-table tbody td {
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding: 0.55rem 0.8rem;
vertical-align: middle;
}
.bb-attachment-extension-name {
@@ -2343,16 +2392,6 @@ a {
.bb-attachment-extension-form {
grid-template-columns: 1fr;
}
.bb-attachment-extension-header,
.bb-attachment-extension-row {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.bb-attachment-extension-actions {
justify-content: flex-start;
}
}
.bb-rank-main img {

View File

@@ -116,6 +116,14 @@ export default function Acp({ isAdmin }) {
const [attachmentExtensionSaving, setAttachmentExtensionSaving] = useState(false)
const [attachmentExtensionSavingId, setAttachmentExtensionSavingId] = useState(null)
const [attachmentSeedSaving, setAttachmentSeedSaving] = useState(false)
const [attachmentSettingsSaving, setAttachmentSettingsSaving] = useState(false)
const [attachmentSettings, setAttachmentSettings] = useState({
display_images_inline: 'true',
create_thumbnails: 'true',
thumbnail_max_width: '300',
thumbnail_max_height: '300',
thumbnail_quality: '85',
})
const [showAttachmentExtensionModal, setShowAttachmentExtensionModal] = useState(false)
const [attachmentExtensionEdit, setAttachmentExtensionEdit] = useState(null)
const [showAttachmentExtensionDelete, setShowAttachmentExtensionDelete] = useState(false)
@@ -241,6 +249,13 @@ export default function Acp({ isAdmin }) {
favicon_256: settingsMap.get('favicon_256') || '',
}
setGeneralSettings(next)
setAttachmentSettings({
display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true',
create_thumbnails: settingsMap.get('attachments.create_thumbnails') || 'true',
thumbnail_max_width: settingsMap.get('attachments.thumbnail_max_width') || '300',
thumbnail_max_height: settingsMap.get('attachments.thumbnail_max_height') || '300',
thumbnail_quality: settingsMap.get('attachments.thumbnail_quality') || '85',
})
} catch (err) {
if (active) setGeneralError(err.message)
}
@@ -297,6 +312,24 @@ export default function Acp({ isAdmin }) {
}
}
const handleAttachmentSettingsSave = async (event) => {
event.preventDefault()
setAttachmentSettingsSaving(true)
setAttachmentGroupsError('')
try {
await saveSettings(
Object.entries(attachmentSettings).map(([key, value]) => ({
key: `attachments.${key}`,
value: typeof value === 'string' ? value.trim() : String(value ?? ''),
}))
)
} catch (err) {
setAttachmentGroupsError(err.message)
} finally {
setAttachmentSettingsSaving(false)
}
}
const handleLogoUpload = async (file, settingKey) => {
if (!file) return
setGeneralUploading(true)
@@ -1656,44 +1689,60 @@ export default function Acp({ isAdmin }) {
className="bb-attachment-extension-table"
style={{ marginLeft: (depth + 1) * 16 }}
>
<div className="bb-attachment-extension-header tr-header">
<span>{t('attachment.extension')}</span>
<span>{t('attachment.allowed_mimes')}</span>
<span>{t('attachment.extension_group')}</span>
<span>{t('attachment.actions')}</span>
</div>
{groupExtensions.map((extension) => (
<div key={extension.id} className="bb-attachment-extension-row">
<span className="bb-attachment-extension-name">
{extension.extension}
</span>
<span className="bb-attachment-extension-meta">
{(extension.allowed_mimes || []).join(', ')}
</span>
<span className="bb-attachment-extension-meta">
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|| t('attachment.extension_unassigned')}
</span>
<div className="bb-attachment-extension-actions">
<ButtonGroup size="sm" className="bb-action-group">
<Button
variant="dark"
onClick={() => openAttachmentExtensionEdit(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleAttachmentExtensionDelete(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</div>
))}
<table className="table table-sm mb-0">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">
{t('attachment.extension')}
</th>
<th scope="col" className="text-start">
{t('attachment.allowed_mimes')}
</th>
<th scope="col" className="text-start">
{t('attachment.extension_group')}
</th>
<th scope="col" className="text-start">
{t('attachment.actions')}
</th>
</tr>
</thead>
<tbody>
{groupExtensions.map((extension) => (
<tr key={extension.id} className="bb-attachment-extension-row">
<td className="bb-attachment-extension-name text-start">
{extension.extension}
</td>
<td className="bb-attachment-extension-meta text-start">
{(extension.allowed_mimes || []).join(', ')}
</td>
<td className="bb-attachment-extension-meta text-start">
{attachmentGroups.find((group) => group.id === extension.attachment_group_id)?.name
|| t('attachment.extension_unassigned')}
</td>
<td>
<div className="bb-attachment-extension-actions">
<ButtonGroup size="sm" className="bb-action-group">
<Button
variant="dark"
onClick={() => openAttachmentExtensionEdit(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-pencil" aria-hidden="true" />
</Button>
<Button
variant="dark"
onClick={() => handleAttachmentExtensionDelete(extension)}
disabled={attachmentExtensionSaving}
>
<i className="bi bi-trash" aria-hidden="true" />
</Button>
</ButtonGroup>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
@@ -2143,297 +2192,376 @@ export default function Acp({ isAdmin }) {
<h2 className="mb-4">{t('acp.title')}</h2>
<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.forum_name}
onChange={(event) =>
setGeneralSettings((prev) => ({ ...prev, forum_name: 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.show_header_name !== 'false'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
show_header_name: event.target.checked ? 'true' : 'false',
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.default_theme}
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.accent_color_dark}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_dark || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
/>
<Row className="g-4">
<Col lg={3} xl={2}>
<div className="bb-acp-sidebar">
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.groups')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.ranks')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.attachments')}
</button>
</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.accent_color_light}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_light || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
/>
</div>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.board_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action is-active">
{t('acp.general')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.forums')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.users')}
</button>
</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.logo_dark ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_dark} 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>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.client_communication')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.authentication')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.email_settings')}
</button>
</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.logo_light ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_light} 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>
<div className="bb-acp-sidebar-section">
<div className="bb-acp-sidebar-title">{t('acp.server_configuration')}</div>
<div className="list-group">
<button type="button" className="list-group-item list-group-item-action">
{t('acp.security_settings')}
</button>
<button type="button" className="list-group-item list-group-item-action">
{t('acp.search_settings')}
</button>
</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.favicon_ico ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_ico} 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.favicon_16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_16} 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.favicon_32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_32} 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.favicon_48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_48} 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.favicon_64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_64} 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.favicon_128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_128} 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.favicon_256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_256} 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>
</div>
</div>
</Col>
<Col lg={9} xl={10}>
<div className="bb-acp-panel mb-4">
<div className="bb-acp-panel-header">
<h5 className="mb-1">{t('acp.welcome_title')}</h5>
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
</div>
</div>
{generalError && <p className="text-danger">{generalError}</p>}
<div className="bb-acp-panel">
<div className="bb-acp-panel-header">
<h5 className="mb-0">{t('acp.general_settings')}</h5>
</div>
<div className="bb-acp-panel-body">
<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.forum_name}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
forum_name: 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.show_header_name !== 'false'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
show_header_name: event.target.checked ? 'true' : 'false',
}))
}
/>
</Form.Group>
</Col>
<Col lg={6}>
<Form.Group>
<Form.Label>{t('acp.default_theme')}</Form.Label>
<Form.Select
value={generalSettings.default_theme}
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.accent_color_dark}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_dark || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_dark: 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.accent_color_light}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: event.target.value,
}))
}
placeholder="#f29b3f"
/>
<Form.Control
type="color"
value={generalSettings.accent_color_light || '#f29b3f'}
onChange={(event) =>
setGeneralSettings((prev) => ({
...prev,
accent_color_light: 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.logo_dark ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_dark} 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.logo_light ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.logo_light} 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.favicon_ico ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_ico} 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.favicon_16 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_16} 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.favicon_32 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_32} 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.favicon_48 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_48} 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.favicon_64 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_64} 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.favicon_128 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_128} 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.favicon_256 ? (
<div className="bb-dropzone-preview">
<img src={generalSettings.favicon_256} 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>
</div>
</div>
</Col>
</Row>
</Tab>
<Tab eventKey="forums" title={t('acp.forums')}>
<p className="bb-muted">{t('acp.forums_hint')}</p>
@@ -2702,6 +2830,95 @@ export default function Acp({ isAdmin }) {
{attachmentGroupsError && <p className="text-danger">{attachmentGroupsError}</p>}
{attachmentExtensionsError && <p className="text-danger">{attachmentExtensionsError}</p>}
<div className="bb-attachment-admin">
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.settings_title')}</h5>
</div>
<Form onSubmit={handleAttachmentSettingsSave}>
<div className="row g-3 align-items-end">
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-display-inline"
label={t('attachment.display_images_inline')}
checked={attachmentSettings.display_images_inline !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
display_images_inline: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-6">
<Form.Check
type="switch"
id="attachment-create-thumbnails"
label={t('attachment.create_thumbnails')}
checked={attachmentSettings.create_thumbnails !== 'false'}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
create_thumbnails: event.target.checked ? 'true' : 'false',
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_width')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_width}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_width: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_max_height')}</Form.Label>
<Form.Control
type="number"
min="0"
value={attachmentSettings.thumbnail_max_height}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_max_height: event.target.value,
}))
}
/>
</div>
<div className="col-12 col-md-4">
<Form.Label>{t('attachment.thumbnail_quality')}</Form.Label>
<Form.Control
type="number"
min="10"
max="95"
value={attachmentSettings.thumbnail_quality}
onChange={(event) =>
setAttachmentSettings((prev) => ({
...prev,
thumbnail_quality: event.target.value,
}))
}
/>
</div>
</div>
<div className="mt-3">
<Button
type="submit"
className="bb-accent-button"
disabled={attachmentSettingsSaving}
>
{attachmentSettingsSaving ? t('form.saving') : t('acp.save')}
</Button>
</div>
</Form>
</div>
<div className="bb-attachment-admin-section">
<div className="d-flex justify-content-between align-items-center mb-3">
<h5 className="mb-0">{t('attachment.groups_title')}</h5>

View File

@@ -34,6 +34,7 @@ export default function ThreadView() {
const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([])
const [lightboxImage, setLightboxImage] = useState('')
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
disableBbcode: false,
@@ -452,19 +453,40 @@ export default function ThreadView() {
return (
<div className="bb-attachment-list">
{attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.download_url}
className="bb-attachment-item"
download
>
<i className="bi bi-paperclip" aria-hidden="true" />
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</a>
attachment.is_image ? (
<button
key={attachment.id}
type="button"
className="bb-attachment-item border-0 text-start"
onClick={() => setLightboxImage(attachment.download_url)}
>
<img
src={attachment.thumbnail_url || attachment.download_url}
alt={attachment.original_name}
className="img-fluid rounded"
style={{ width: 72, height: 72, objectFit: 'cover' }}
/>
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</button>
) : (
<a
key={attachment.id}
href={attachment.download_url}
className="bb-attachment-item"
download
>
<i className="bi bi-paperclip" aria-hidden="true" />
<span className="bb-attachment-name">{attachment.original_name}</span>
<span className="bb-attachment-meta">
{attachment.mime_type}
{attachment.size_bytes ? ` · ${formatBytes(attachment.size_bytes)}` : ''}
</span>
</a>
)
))}
</div>
)
@@ -727,6 +749,12 @@ export default function ThreadView() {
</div>
<div
className="bb-post-body"
onClick={(event) => {
if (event.target?.tagName === 'IMG') {
event.preventDefault()
setLightboxImage(event.target.src)
}
}}
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
/>
{renderAttachments(post.attachments)}
@@ -852,6 +880,18 @@ export default function ThreadView() {
/>
</Modal.Body>
</Modal>
<Modal
show={Boolean(lightboxImage)}
onHide={() => setLightboxImage('')}
centered
size="lg"
>
<Modal.Body className="text-center">
{lightboxImage && (
<img src={lightboxImage} alt="" className="img-fluid rounded" />
)}
</Modal.Body>
</Modal>
</Container>
)
}

View File

@@ -51,6 +51,16 @@
"acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ",
"acp.general": "Allgemein",
"acp.quick_access": "Schnellzugriff",
"acp.board_configuration": "Board-Konfiguration",
"acp.client_communication": "Client-Kommunikation",
"acp.server_configuration": "Server-Konfiguration",
"acp.authentication": "Authentifizierung",
"acp.email_settings": "E-Mail-Einstellungen",
"acp.security_settings": "Sicherheitseinstellungen",
"acp.search_settings": "Sucheinstellungen",
"acp.welcome_title": "Willkommen bei speedBB",
"acp.general_settings": "Allgemeine Einstellungen",
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
"acp.loading": "Laden...",
"acp.new_category": "Neue Kategorie",
@@ -217,6 +227,12 @@
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
"ucp.custom_color": "Eigene Farbe",
"attachment.groups_title": "Anhanggruppen",
"attachment.settings_title": "Anhang-Einstellungen",
"attachment.display_images_inline": "Bilder inline anzeigen",
"attachment.create_thumbnails": "Vorschaubilder erstellen",
"attachment.thumbnail_max_width": "Maximale Vorschaubreite (px)",
"attachment.thumbnail_max_height": "Maximale Vorschaubildhöhe (px)",
"attachment.thumbnail_quality": "Vorschaubild-Qualität (JPEG/WebP)",
"attachment.group_create": "Neue Anhanggruppe",
"attachment.group_create_title": "Anhanggruppe erstellen",
"attachment.group_edit_title": "Anhanggruppe bearbeiten",

View File

@@ -51,6 +51,16 @@
"acp.forums_tree": "Forum tree",
"acp.forums_type": "Type",
"acp.general": "General",
"acp.quick_access": "Quick access",
"acp.board_configuration": "Board configuration",
"acp.client_communication": "Client communication",
"acp.server_configuration": "Server configuration",
"acp.authentication": "Authentication",
"acp.email_settings": "Email settings",
"acp.security_settings": "Security settings",
"acp.search_settings": "Search settings",
"acp.welcome_title": "Welcome to speedBB",
"acp.general_settings": "General settings",
"acp.general_hint": "Global settings and board configuration will appear here.",
"acp.loading": "Loading...",
"acp.new_category": "New category",
@@ -217,6 +227,12 @@
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
"ucp.custom_color": "Custom color",
"attachment.groups_title": "Attachment groups",
"attachment.settings_title": "Attachment settings",
"attachment.display_images_inline": "Display images inline",
"attachment.create_thumbnails": "Create thumbnails",
"attachment.thumbnail_max_width": "Maximum thumbnail width (px)",
"attachment.thumbnail_max_height": "Maximum thumbnail height (px)",
"attachment.thumbnail_quality": "Thumbnail quality (JPEG/WebP)",
"attachment.group_create": "New attachment group",
"attachment.group_create_title": "Create attachment group",
"attachment.group_edit_title": "Edit attachment group",