added attchments
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-28 19:34:25 +01:00
parent 2409feb06f
commit c33cde6f04
32 changed files with 4618 additions and 213 deletions

View File

@@ -117,6 +117,13 @@ export async function fetchPortalSummary() {
return apiFetch('/portal/summary')
}
export async function previewBbcode(body) {
return apiFetch('/preview', {
method: 'POST',
body: JSON.stringify({ body }),
})
}
export async function fetchSetting(key) {
// TODO: Prefer fetchSettings() when multiple settings are needed.
const cacheBust = Date.now()
@@ -256,6 +263,90 @@ export async function updateThreadSolved(threadId, solved) {
})
}
export async function listAttachmentsByThread(threadId) {
return getCollection(`/attachments?thread=/api/threads/${threadId}`)
}
export async function listAttachmentsByPost(postId) {
return getCollection(`/attachments?post=/api/posts/${postId}`)
}
export async function uploadAttachment({ threadId, postId, file }) {
const body = new FormData()
if (threadId) body.append('thread', `/api/threads/${threadId}`)
if (postId) body.append('post', `/api/posts/${postId}`)
body.append('file', file)
return apiFetch('/attachments', {
method: 'POST',
body,
})
}
export async function deleteAttachment(id) {
return apiFetch(`/attachments/${id}`, {
method: 'DELETE',
})
}
export async function listAttachmentGroups() {
return getCollection('/attachment-groups')
}
export async function createAttachmentGroup(payload) {
return apiFetch('/attachment-groups', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentGroup(id, payload) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentGroup(id) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'DELETE',
})
}
export async function reorderAttachmentGroups(parentId, orderedIds) {
return apiFetch('/attachment-groups/reorder', {
method: 'POST',
body: JSON.stringify({ parentId, orderedIds }),
})
}
export async function listAttachmentExtensions() {
return getCollection('/attachment-extensions')
}
export async function listAttachmentExtensionsPublic() {
return getCollection('/attachment-extensions/public')
}
export async function createAttachmentExtension(payload) {
return apiFetch('/attachment-extensions', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentExtension(id, payload) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentExtension(id) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'DELETE',
})
}
export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`)
}

View File

@@ -170,6 +170,322 @@ a {
flex-wrap: wrap;
}
.bb-thread-attachments {
border: 1px solid var(--bb-border);
border-radius: 12px;
padding: 0.8rem 1rem;
background: #141822;
}
.bb-thread-attachments-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
font-weight: 600;
color: var(--bb-ink);
margin-bottom: 0.6rem;
}
.bb-thread-attachments-actions {
display: flex;
align-items: center;
gap: 0.6rem;
flex-wrap: wrap;
}
.bb-thread-attachments-actions input[type='file'] {
max-width: 280px;
color: var(--bb-ink-muted);
}
.bb-thread-modal.modal-dialog {
max-width: 95vw !important;
width: 95vw !important;
height: 95vh;
margin: 2.5vh auto;
}
.bb-thread-modal.modal-dialog .modal-content {
height: 95vh;
width: 100%;
}
.bb-thread-modal.modal-dialog .modal-body {
overflow-y: auto;
}
.bb-attachment-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.bb-post-content .bb-attachment-list {
margin-top: 0.4rem;
}
.bb-attachment-item {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--bb-ink);
text-decoration: none;
padding: 0.35rem 0.5rem;
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-attachment-item .bi-paperclip {
color: var(--bb-accent, #f29b3f);
}
.bb-attachment-item:hover {
border-color: var(--bb-accent, #f29b3f);
color: var(--bb-accent, #f29b3f);
}
.bb-attachment-name {
font-weight: 600;
}
.bb-attachment-meta {
color: var(--bb-ink-muted);
font-size: 0.8rem;
}
.bb-attachment-panel {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
background: rgba(18, 23, 33, 0.9);
overflow: hidden;
}
.bb-attachment-tabs {
display: flex;
gap: 0.4rem;
padding: 0.6rem 0.8rem 0;
}
.bb-attachment-tab {
border: none;
background: #1a202b;
color: var(--bb-ink-muted);
padding: 0.35rem 0.75rem;
border-radius: 8px 8px 0 0;
font-size: 0.85rem;
font-weight: 600;
}
.bb-attachment-tab.is-active {
color: var(--bb-accent, #f29b3f);
background: #202735;
}
.bb-attachment-body {
padding: 0.8rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.bb-attachment-actions {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.8rem;
}
.bb-attachment-drop {
border: 2px dashed color-mix(in srgb, var(--bb-accent, #f29b3f) 65%, transparent);
border-radius: 12px;
padding: 0.85rem 1rem;
background: rgba(18, 23, 33, 0.6);
color: var(--bb-ink-muted);
text-align: center;
margin-bottom: 0.8rem;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
}
.bb-attachment-drop.is-dragover {
border-color: var(--bb-accent, #f29b3f);
background: rgba(242, 155, 63, 0.12);
color: var(--bb-ink);
}
.bb-attachment-drop-link {
border: 0;
padding: 0;
background: transparent;
color: var(--bb-accent, #f29b3f);
font-weight: 600;
text-decoration: underline;
}
.bb-attachment-drop-link:hover {
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
}
.bb-attachment-input {
display: none;
}
.bb-attachment-table {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
overflow: hidden;
margin-bottom: 0;
color: var(--bb-ink);
}
.bb-attachment-table thead th {
font-size: 0.7rem;
text-transform: none;
letter-spacing: 0.02em;
color: var(--bb-ink-muted);
background: rgba(255, 255, 255, 0.03);
text-align: left;
border-bottom: 0;
padding: 0.6rem 0.8rem;
}
.bb-attachment-table tbody td {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding: 0.6rem 0.8rem;
vertical-align: middle;
}
.bb-attachment-table thead th:nth-child(3),
.bb-attachment-table tbody td:nth-child(3) {
width: 90px;
}
.bb-attachment-table thead th:nth-child(4),
.bb-attachment-table tbody td:nth-child(4) {
width: 80px;
}
.bb-attachment-table thead th:nth-child(5),
.bb-attachment-table tbody td:nth-child(5) {
width: 1%;
white-space: nowrap;
}
.bb-attachment-name {
color: var(--bb-accent, #f29b3f);
font-weight: 600;
}
.bb-attachment-size {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-status {
color: #8bd98b;
font-size: 0.85rem;
}
.bb-attachment-comment {
background: #202734;
border-color: rgba(255, 255, 255, 0.08);
color: var(--bb-ink);
font-size: 0.85rem;
}
.bb-attachment-row-actions {
display: inline-flex;
align-items: center;
gap: 0;
justify-content: flex-end;
background: var(--bb-accent, #f29b3f);
border-radius: 10px;
padding: 0.2rem;
width: fit-content;
}
.bb-attachment-action {
border: 0;
background: transparent;
color: #0e121b;
width: 36px;
height: 32px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.bb-attachment-action:hover {
transform: translateY(-1px);
background: color-mix(in srgb, #fff 18%, transparent);
color: #0e121b;
}
.bb-attachment-remove {
border: none;
background: rgba(255, 255, 255, 0.06);
color: var(--bb-ink-muted);
width: 32px;
height: 28px;
border-radius: 6px;
}
.bb-attachment-remove:hover {
color: #f07f7f;
background: rgba(240, 127, 127, 0.12);
}
.bb-attachment-empty {
padding: 0.8rem;
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-options {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.2rem 0;
}
.bb-attachment-options .form-check-label {
color: var(--bb-ink);
}
.bb-attachment-options .form-check-input {
background-color: #1a202b;
border-color: rgba(255, 255, 255, 0.2);
}
.bb-attachment-options .form-check-input:checked {
background-color: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
}
.form-control:focus,
.form-select:focus,
.form-check-input:focus {
border-color: var(--bb-accent, #f29b3f);
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 30%, transparent);
}
.tr-header {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.tr-header th {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.rdt_TableHeadRow {
border-bottom: 3px solid var(--bb-accent, #f29b3f);
}
.bb-thread-actions {
display: flex;
align-items: center;
@@ -351,6 +667,9 @@ a {
.bb-post-content {
padding: 1rem 1.35rem 1.2rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.bb-post-header {
@@ -412,21 +731,19 @@ a {
.bb-post-content {
position: relative;
padding-bottom: 3.5rem;
}
.bb-post-body {
white-space: pre-wrap;
color: var(--bb-ink);
line-height: 1.6;
flex: 1 1 auto;
}
.bb-post-footer {
position: absolute;
right: 1rem;
bottom: 1rem;
display: flex;
justify-content: flex-end;
margin-top: auto;
}
.bb-thread-reply {
@@ -1805,6 +2122,14 @@ a {
color: #0e121b;
}
.bb-accent-button:disabled,
.bb-accent-button.disabled {
background: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
color: #0e121b;
opacity: 0.6;
}
.modal-content .modal-header {
background: #0f1218;
color: #e6e8eb;
@@ -1921,6 +2246,115 @@ a {
gap: 0.6rem;
}
.bb-attachment-type-main {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.bb-attachment-type-title {
font-weight: 600;
color: var(--bb-ink);
}
.bb-attachment-type-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-attachment-type-rules {
margin-top: 0.2rem;
color: var(--bb-ink-muted);
font-size: 0.8rem;
}
.bb-attachment-admin {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bb-attachment-extension-form {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.2fr) minmax(160px, 1fr) auto;
gap: 0.75rem;
align-items: center;
margin-bottom: 1rem;
}
.bb-attachment-extension-table {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.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-header {
font-size: 0.8rem;
color: var(--bb-ink-muted);
background: rgba(15, 19, 27, 0.7);
}
.bb-attachment-extension-row {
background: rgba(18, 23, 33, 0.8);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.bb-attachment-extension-name {
font-weight: 600;
color: var(--bb-ink);
}
.bb-attachment-extension-actions {
display: flex;
justify-content: flex-end;
}
.bb-attachment-extension-meta {
color: var(--bb-ink-muted);
font-size: 0.9rem;
}
.bb-attachment-tree-toggle {
cursor: pointer;
flex: 1 1 auto;
}
.bb-attachment-tree-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--bb-accent, #f29b3f) 70%, #000);
outline-offset: 2px;
border-radius: 10px;
}
@media (max-width: 900px) {
.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 {
height: 22px;
width: auto;
@@ -2184,6 +2618,7 @@ a {
}
}
.bb-collapse-toggle {
width: 20px;
height: 20px;

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,15 @@
import { useEffect, useState } from 'react'
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
import { Link, useParams } from 'react-router-dom'
import { createThread, getForum, listAllForums, listThreadsByForum } from '../api/client'
import {
createThread,
getForum,
listAllForums,
listThreadsByForum,
uploadAttachment,
listAttachmentExtensionsPublic,
previewBbcode,
} from '../api/client'
import PortalTopicRow from '../components/PortalTopicRow'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
@@ -18,6 +26,24 @@ export default function ForumView() {
const [title, setTitle] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const [threadFiles, setThreadFiles] = useState([])
const [uploading, setUploading] = useState(false)
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
const [attachmentValidationError, setAttachmentValidationError] = useState('')
const [threadDropActive, setThreadDropActive] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([])
const [attachmentTab, setAttachmentTab] = useState('options')
const [attachmentOptions, setAttachmentOptions] = useState({
disableBbcode: false,
disableSmilies: false,
disableAutoUrls: false,
attachSignature: true,
notifyReplies: false,
lockTopic: false,
})
const { t } = useTranslation()
const renderChildRows = (nodes) =>
@@ -178,24 +204,388 @@ export default function ForumView() {
}
}, [id])
useEffect(() => {
listAttachmentExtensionsPublic()
.then((data) => {
if (Array.isArray(data)) {
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
}
})
.catch(() => {})
}, [])
const handleSubmit = async (event) => {
event.preventDefault()
setSaving(true)
setError('')
try {
await createThread({ title, body, forumId: id })
const created = await createThread({ title, body, forumId: id })
if (threadFiles.length > 0 && created?.id) {
setUploading(true)
for (const entry of threadFiles) {
await uploadAttachment({ threadId: created.id, file: entry.file })
}
}
setTitle('')
setBody('')
setThreadFiles([])
const updated = await listThreadsByForum(id)
setThreads(updated)
setShowModal(false)
} catch (err) {
setError(err.message)
} finally {
setUploading(false)
setSaving(false)
}
}
const formatBytes = (bytes) => {
if (!bytes && bytes !== 0) return ''
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
const handleInlineInsert = (entry) => {
const marker = `[attachment]${entry.file.name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
const clearPreviewUrls = () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
setPreviewUrls([])
}
const buildPreviewBody = (rawBody, entries) => {
if (!entries || entries.length === 0) {
return { body: rawBody, urls: [] }
}
const urls = []
const map = new Map()
entries.forEach((entry) => {
const file = entry.file
if (!file) return
const url = URL.createObjectURL(file)
urls.push(url)
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
})
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
const key = String(name || '').trim().toLowerCase()
if (!map.has(key)) return match
const { url, mime } = map.get(key)
if (mime.startsWith('image/')) {
return `[img]${url}[/img]`
}
return `[url=${url}]${name}[/url]`
})
return { body: replaced, urls }
}
const handlePreview = async () => {
setPreviewLoading(true)
setError('')
try {
clearPreviewUrls()
const { body: previewBody, urls } = buildPreviewBody(body || '', threadFiles)
const result = await previewBbcode(previewBody || '')
setPreviewHtml(result?.html || '')
setShowPreview(true)
setPreviewUrls(urls)
} catch (err) {
setError(err.message)
} finally {
setPreviewLoading(false)
}
}
const applyThreadFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else {
setAttachmentValidationError('')
}
setThreadFiles(
accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
}))
)
setAttachmentTab('attachments')
}
const appendThreadFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else if (accepted.length > 0) {
setAttachmentValidationError('')
}
if (accepted.length === 0) return
setThreadFiles((prev) => [
...prev,
...accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
})),
])
setAttachmentTab('attachments')
}
const handleThreadPaste = (event) => {
const items = Array.from(event.clipboardData?.items || [])
if (items.length === 0) return
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
if (imageItems.length === 0) return
event.preventDefault()
const files = imageItems
.map((item) => item.getAsFile())
.filter(Boolean)
.map((file) => {
const ext = file.type?.split('/')[1] || 'png'
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
return new File([file], name, { type: file.type })
})
appendThreadFiles(files)
if (files.length > 0) {
const marker = `[attachment]${files[0].name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
}
const renderAttachmentFooter = () => (
<div className="bb-attachment-panel">
<div className="bb-attachment-tabs">
<button
type="button"
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('options')}
>
{t('attachment.tab_options')}
</button>
<button
type="button"
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('attachments')}
>
{t('attachment.tab_attachments')}
</button>
</div>
<div className="bb-attachment-body">
{attachmentTab === 'options' && (
<div className="bb-attachment-options">
<Form.Check
type="checkbox"
id="bb-option-disable-bbcode"
label={t('attachment.option_disable_bbcode')}
checked={attachmentOptions.disableBbcode}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableBbcode: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-disable-smilies"
label={t('attachment.option_disable_smilies')}
checked={attachmentOptions.disableSmilies}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableSmilies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-disable-auto-urls"
label={t('attachment.option_disable_auto_urls')}
checked={attachmentOptions.disableAutoUrls}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
disableAutoUrls: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-attach-signature"
label={t('attachment.option_attach_signature')}
checked={attachmentOptions.attachSignature}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
attachSignature: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-notify-replies"
label={t('attachment.option_notify_replies')}
checked={attachmentOptions.notifyReplies}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
notifyReplies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-option-lock-topic"
label={t('attachment.option_lock_topic')}
checked={attachmentOptions.lockTopic}
onChange={(event) =>
setAttachmentOptions((prev) => ({
...prev,
lockTopic: event.target.checked,
}))
}
/>
</div>
)}
{attachmentTab === 'attachments' && (
<>
<p className="bb-muted mb-2">
{t('attachment.hint')}
</p>
<p className="bb-muted mb-3">
{t('attachment.max_size', { size: '25 MB' })}
</p>
<div className="bb-attachment-actions">
<Button
type="button"
size="sm"
variant="outline-secondary"
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
>
{t('attachment.add_files')}
</Button>
</div>
{attachmentValidationError && (
<p className="text-danger mb-2">{attachmentValidationError}</p>
)}
<table className="table bb-attachment-table">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">{t('attachment.filename')}</th>
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
<th scope="col" className="text-start">{t('attachment.size')}</th>
<th scope="col" className="text-start">{t('attachment.status')}</th>
<th scope="col" className="text-start">{t('attachment.actions')}</th>
</tr>
</thead>
<tbody>
{threadFiles.length === 0 && (
<tr>
<td colSpan={5} className="bb-attachment-empty">
{t('attachment.empty')}
</td>
</tr>
)}
{threadFiles.map((entry) => (
<tr key={entry.id} className="bb-attachment-row">
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
{entry.file.name}
</td>
<td className="bb-attachment-cell-comment">
<Form.Control
className="bb-attachment-comment"
value={entry.comment}
onChange={(event) =>
setThreadFiles((prev) =>
prev.map((item) =>
item.id === entry.id
? { ...item, comment: event.target.value }
: item
)
)
}
placeholder={t('attachment.file_comment_placeholder')}
/>
</td>
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
{formatBytes(entry.file.size)}
</td>
<td className="bb-attachment-status text-center">
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
</td>
<td>
<div className="bb-attachment-row-actions">
<button
type="button"
className="bb-attachment-action"
onClick={() => handleInlineInsert(entry)}
title={t('attachment.place_inline')}
aria-label={t('attachment.place_inline')}
>
<i className="bi bi-paperclip" aria-hidden="true" />
</button>
<button
type="button"
className="bb-attachment-action"
onClick={() =>
setThreadFiles((prev) =>
prev.filter((item) => item.id !== entry.id)
)
}
title={t('attachment.delete_file')}
aria-label={t('attachment.delete_file')}
>
<i className="bi bi-trash" aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
)
return (
<Container fluid className="py-5 bb-shell-container">
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
@@ -258,7 +648,7 @@ export default function ForumView() {
</div>
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<div className="bb-portal-topic-header tr-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>
@@ -284,13 +674,19 @@ export default function ForumView() {
</>
)}
{forum?.type === 'forum' && (
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
<Modal
show={showModal}
onHide={() => setShowModal(false)}
centered
size="lg"
dialogClassName="bb-thread-modal"
>
<Modal.Header closeButton>
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Modal.Body className="d-flex flex-column p-0">
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
<Form onSubmit={handleSubmit}>
<Form onSubmit={handleSubmit} className="d-flex flex-column flex-grow-1 px-3 pb-3 pt-2">
<Form.Group className="mb-3">
<Form.Label>{t('form.title')}</Form.Label>
<Form.Control
@@ -302,30 +698,113 @@ export default function ForumView() {
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Group className="mb-3 d-flex flex-column flex-grow-1">
<Form.Label>{t('form.body')}</Form.Label>
<Form.Control
as="textarea"
rows={6}
className="flex-grow-1"
placeholder={t('form.thread_body_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleThreadPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<div className="d-flex gap-2 justify-content-between">
<Form.Control
id="bb-thread-attachment-input"
type="file"
multiple
className="bb-attachment-input"
disabled={!token || saving || uploading}
onChange={(event) => {
applyThreadFiles(event.target.files)
event.target.value = ''
}}
/>
<div
className={`bb-attachment-drop ${threadDropActive ? 'is-dragover' : ''}`}
role="button"
tabIndex={0}
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
document.getElementById('bb-thread-attachment-input')?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setThreadDropActive(true)
}}
onDragLeave={() => setThreadDropActive(false)}
onDrop={(event) => {
event.preventDefault()
setThreadDropActive(false)
applyThreadFiles(event.dataTransfer.files)
}}
>
<span>
{t('attachment.drop_hint')}{' '}
<button
type="button"
className="bb-attachment-drop-link"
onClick={(event) => {
event.stopPropagation()
document.getElementById('bb-thread-attachment-input')?.click()
}}
>
{t('attachment.drop_browse')}
</button>
</span>
</div>
{renderAttachmentFooter()}
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
<div className="d-flex gap-2">
<Button
type="button"
variant="outline-secondary"
onClick={handlePreview}
disabled={!token || saving || uploading || previewLoading}
>
{t('form.preview')}
</Button>
<Button
type="submit"
className="bb-accent-button"
disabled={!token || saving || uploading}
>
{saving || uploading ? t('form.posting') : t('form.create_thread')}
</Button>
</div>
</Modal.Footer>
</Form>
</Modal.Body>
</Modal>
)}
<Modal
show={showPreview}
onHide={() => {
setShowPreview(false)
clearPreviewUrls()
}}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{t('form.preview')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div
className="bb-post-body"
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
/>
</Modal.Body>
</Modal>
</Container>
)
}

View File

@@ -235,7 +235,7 @@ export default function Home() {
)}
{!loadingThreads && recentThreads.length > 0 && (
<div className="bb-portal-topic-table">
<div className="bb-portal-topic-header">
<div className="bb-portal-topic-header tr-header">
<span>{t('portal.topic')}</span>
<span>{t('thread.replies')}</span>
<span>{t('thread.views')}</span>

View File

@@ -1,7 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Container, Form } from 'react-bootstrap'
import { Button, Container, Form, Modal } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
import { createPost, getThread, listPostsByThread, updateThreadSolved } from '../api/client'
import {
createPost,
getThread,
listPostsByThread,
updateThreadSolved,
uploadAttachment,
listAttachmentExtensionsPublic,
previewBbcode,
} from '../api/client'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from 'react-i18next'
@@ -15,6 +23,25 @@ export default function ThreadView() {
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const [solving, setSolving] = useState(false)
const [threadFiles, setThreadFiles] = useState([])
const [threadUploading, setThreadUploading] = useState(false)
const [replyFiles, setReplyFiles] = useState([])
const [replyUploading, setReplyUploading] = useState(false)
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
const [attachmentValidationError, setAttachmentValidationError] = useState('')
const [replyDropActive, setReplyDropActive] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([])
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
disableBbcode: false,
disableSmilies: false,
disableAutoUrls: false,
attachSignature: true,
notifyReplies: false,
})
const { t } = useTranslation()
const replyRef = useRef(null)
@@ -29,6 +56,16 @@ export default function ThreadView() {
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
listAttachmentExtensionsPublic()
.then((data) => {
if (Array.isArray(data)) {
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
}
})
.catch(() => {})
}, [])
useEffect(() => {
if (!thread && posts.length === 0) return
const hash = window.location.hash
@@ -46,13 +83,21 @@ export default function ThreadView() {
setSaving(true)
setError('')
try {
await createPost({ body, threadId: id })
const created = await createPost({ body, threadId: id })
if (replyFiles.length > 0 && created?.id) {
setReplyUploading(true)
for (const entry of replyFiles) {
await uploadAttachment({ postId: created.id, file: entry.file })
}
}
setBody('')
setReplyFiles([])
const updated = await listPostsByThread(id)
setPosts(updated)
} catch (err) {
setError(err.message)
} finally {
setReplyUploading(false)
setSaving(false)
}
}
@@ -67,11 +112,369 @@ export default function ThreadView() {
const year = String(date.getFullYear())
return `${day}.${month}.${year}`
}
const formatBytes = (bytes) => {
if (!bytes && bytes !== 0) return ''
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
const handleInlineInsert = (entry) => {
const marker = `[attachment]${entry.file.name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
const clearPreviewUrls = () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
setPreviewUrls([])
}
const buildPreviewBody = (rawBody, entries) => {
if (!entries || entries.length === 0) {
return { body: rawBody, urls: [] }
}
const urls = []
const map = new Map()
entries.forEach((entry) => {
const file = entry.file
if (!file) return
const url = URL.createObjectURL(file)
urls.push(url)
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
})
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
const key = String(name || '').trim().toLowerCase()
if (!map.has(key)) return match
const { url, mime } = map.get(key)
if (mime.startsWith('image/')) {
return `[img]${url}[/img]`
}
return `[url=${url}]${name}[/url]`
})
return { body: replaced, urls }
}
const handlePreview = async () => {
setPreviewLoading(true)
setError('')
try {
clearPreviewUrls()
const { body: previewBody, urls } = buildPreviewBody(body || '', replyFiles)
const result = await previewBbcode(previewBody || '')
setPreviewHtml(result?.html || '')
setShowPreview(true)
setPreviewUrls(urls)
} catch (err) {
setError(err.message)
} finally {
setPreviewLoading(false)
}
}
const applyReplyFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else {
setAttachmentValidationError('')
}
setReplyFiles(
accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
}))
)
setReplyAttachmentTab('attachments')
}
const appendReplyFiles = (files) => {
const fileList = Array.from(files || [])
const allowed = allowedAttachmentExtensions
const rejected = []
const accepted = fileList.filter((file) => {
const ext = file.name.split('.').pop()?.toLowerCase() || ''
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
rejected.push(file.name)
return false
}
return true
})
if (rejected.length > 0) {
setAttachmentValidationError(
t('attachment.invalid_extensions', { names: rejected.join(', ') })
)
} else if (accepted.length > 0) {
setAttachmentValidationError('')
}
if (accepted.length === 0) return
setReplyFiles((prev) => [
...prev,
...accepted.map((file) => ({
id: `${file.name}-${file.lastModified}`,
file,
comment: '',
})),
])
setReplyAttachmentTab('attachments')
}
const handleReplyPaste = (event) => {
const items = Array.from(event.clipboardData?.items || [])
if (items.length === 0) return
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
if (imageItems.length === 0) return
event.preventDefault()
const files = imageItems
.map((item) => item.getAsFile())
.filter(Boolean)
.map((file) => {
const ext = file.type?.split('/')[1] || 'png'
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
return new File([file], name, { type: file.type })
})
appendReplyFiles(files)
if (files.length > 0) {
const marker = `[attachment]${files[0].name}[/attachment]`
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
}
}
const renderAttachmentFooter = () => (
<div className="bb-attachment-panel">
<div className="bb-attachment-tabs">
<button
type="button"
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('options')}
>
{t('attachment.tab_options')}
</button>
<button
type="button"
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('attachments')}
>
{t('attachment.tab_attachments')}
</button>
</div>
<div className="bb-attachment-body">
{replyAttachmentTab === 'options' && (
<div className="bb-attachment-options">
<Form.Check
type="checkbox"
id="bb-reply-option-disable-bbcode"
label={t('attachment.option_disable_bbcode')}
checked={replyAttachmentOptions.disableBbcode}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableBbcode: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-disable-smilies"
label={t('attachment.option_disable_smilies')}
checked={replyAttachmentOptions.disableSmilies}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableSmilies: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-disable-auto-urls"
label={t('attachment.option_disable_auto_urls')}
checked={replyAttachmentOptions.disableAutoUrls}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
disableAutoUrls: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-attach-signature"
label={t('attachment.option_attach_signature')}
checked={replyAttachmentOptions.attachSignature}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
attachSignature: event.target.checked,
}))
}
/>
<Form.Check
type="checkbox"
id="bb-reply-option-notify-replies"
label={t('attachment.option_notify_replies')}
checked={replyAttachmentOptions.notifyReplies}
onChange={(event) =>
setReplyAttachmentOptions((prev) => ({
...prev,
notifyReplies: event.target.checked,
}))
}
/>
</div>
)}
{replyAttachmentTab === 'attachments' && (
<>
<p className="bb-muted mb-2">
{t('attachment.hint')}
</p>
<p className="bb-muted mb-3">
{t('attachment.max_size', { size: '25 MB' })}
</p>
<div className="bb-attachment-actions">
<Button
type="button"
size="sm"
variant="outline-secondary"
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
>
{t('attachment.add_files')}
</Button>
</div>
{attachmentValidationError && (
<p className="text-danger mb-2">{attachmentValidationError}</p>
)}
<table className="table bb-attachment-table">
<thead className="tr-header">
<tr>
<th scope="col" className="text-start">{t('attachment.filename')}</th>
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
<th scope="col" className="text-start">{t('attachment.size')}</th>
<th scope="col" className="text-start">{t('attachment.status')}</th>
<th scope="col" className="text-start">{t('attachment.actions')}</th>
</tr>
</thead>
<tbody>
{replyFiles.length === 0 && (
<tr>
<td colSpan={5} className="bb-attachment-empty">
{t('attachment.empty')}
</td>
</tr>
)}
{replyFiles.map((entry) => (
<tr key={entry.id} className="bb-attachment-row">
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
{entry.file.name}
</td>
<td className="bb-attachment-cell-comment">
<Form.Control
className="bb-attachment-comment"
value={entry.comment}
onChange={(event) =>
setReplyFiles((prev) =>
prev.map((item) =>
item.id === entry.id
? { ...item, comment: event.target.value }
: item
)
)
}
placeholder={t('attachment.file_comment_placeholder')}
/>
</td>
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
{formatBytes(entry.file.size)}
</td>
<td className="bb-attachment-status text-center">
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
</td>
<td>
<div className="bb-attachment-row-actions">
<button
type="button"
className="bb-attachment-action"
onClick={() => handleInlineInsert(entry)}
title={t('attachment.place_inline')}
aria-label={t('attachment.place_inline')}
>
<i className="bi bi-paperclip" aria-hidden="true" />
</button>
<button
type="button"
className="bb-attachment-action"
onClick={() =>
setReplyFiles((prev) =>
prev.filter((item) => item.id !== entry.id)
)
}
title={t('attachment.delete_file')}
aria-label={t('attachment.delete_file')}
>
<i className="bi bi-trash" aria-hidden="true" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
)
const renderAttachments = (attachments) => {
if (!attachments || attachments.length === 0) return null
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>
))}
</div>
)
}
const allPosts = useMemo(() => {
if (!thread) return posts
const rootPost = {
id: `thread-${thread.id}`,
body: thread.body,
body_html: thread.body_html,
created_at: thread.created_at,
user_id: thread.user_id,
user_name: thread.user_name,
@@ -85,6 +488,7 @@ export default function ThreadView() {
user_rank_badge_type: thread.user_rank_badge_type,
user_rank_badge_text: thread.user_rank_badge_text,
user_rank_badge_url: thread.user_rank_badge_url,
attachments: thread.attachments || [],
isRoot: true,
}
return [rootPost, ...posts]
@@ -98,6 +502,10 @@ export default function ThreadView() {
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const canUploadThread = token
&& thread
&& (Number(thread.user_id) === Number(userId) || isAdmin)
const handleToggleSolved = async () => {
if (!thread || solving) return
setSolving(true)
@@ -112,6 +520,25 @@ export default function ThreadView() {
}
}
const handleThreadUpload = async () => {
if (!thread || threadFiles.length === 0 || threadUploading) return
setThreadUploading(true)
setError('')
try {
for (const file of threadFiles) {
await uploadAttachment({ threadId: thread.id, file })
}
setThreadFiles([])
const [threadData, postData] = await Promise.all([getThread(id), listPostsByThread(id)])
setThread(threadData)
setPosts(postData)
} catch (err) {
setError(err.message)
} finally {
setThreadUploading(false)
}
}
const totalPosts = allPosts.length
return (
@@ -175,6 +602,7 @@ export default function ThreadView() {
</div>
</div>
<div className="bb-posts">
{allPosts.map((post, index) => {
const authorName = post.author?.username
@@ -297,7 +725,11 @@ export default function ThreadView() {
)}
</div>
</div>
<div className="bb-post-body">{post.body}</div>
<div
className="bb-post-body"
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
/>
{renderAttachments(post.attachments)}
<div className="bb-post-footer">
<div className="bb-post-actions">
<a href="#top" className="bb-post-action bb-post-action--round" aria-label={t('portal.portal')}>
@@ -325,19 +757,101 @@ export default function ThreadView() {
placeholder={t('form.reply_placeholder')}
value={body}
onChange={(event) => setBody(event.target.value)}
onPaste={handleReplyPaste}
disabled={!token || saving}
required
/>
</Form.Group>
<Form.Control
id="bb-reply-attachment-input"
type="file"
multiple
className="bb-attachment-input"
disabled={!token || saving || replyUploading}
onChange={(event) => {
applyReplyFiles(event.target.files)
event.target.value = ''
}}
/>
<div
className={`bb-attachment-drop ${replyDropActive ? 'is-dragover' : ''}`}
role="button"
tabIndex={0}
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
document.getElementById('bb-reply-attachment-input')?.click()
}
}}
onDragOver={(event) => {
event.preventDefault()
setReplyDropActive(true)
}}
onDragLeave={() => setReplyDropActive(false)}
onDrop={(event) => {
event.preventDefault()
setReplyDropActive(false)
applyReplyFiles(event.dataTransfer.files)
}}
>
<span>
{t('attachment.drop_hint')}{' '}
<button
type="button"
className="bb-attachment-drop-link"
onClick={(event) => {
event.stopPropagation()
document.getElementById('bb-reply-attachment-input')?.click()
}}
>
{t('attachment.drop_browse')}
</button>
</span>
</div>
{renderAttachmentFooter()}
<div className="bb-thread-reply-actions">
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
{saving ? t('form.posting') : t('form.post_reply')}
</Button>
<div className="d-flex gap-2 justify-content-end">
<Button
type="button"
variant="outline-secondary"
onClick={handlePreview}
disabled={!token || saving || replyUploading || previewLoading}
>
{t('form.preview')}
</Button>
<Button
type="submit"
className="bb-accent-button"
disabled={!token || saving || replyUploading}
>
{saving || replyUploading ? t('form.posting') : t('form.post_reply')}
</Button>
</div>
</div>
</Form>
</div>
</div>
)}
<Modal
show={showPreview}
onHide={() => {
setShowPreview(false)
clearPreviewUrls()
}}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{t('form.preview')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div
className="bb-post-body"
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
/>
</Modal.Body>
</Modal>
</Container>
)
}

View File

@@ -46,6 +46,7 @@
"acp.add_forum": "Forum hinzufügen",
"acp.ranks": "Ränge",
"acp.groups": "Gruppen",
"acp.attachments": "Anh\u00e4nge",
"acp.forums_parent_root": "Wurzel (kein Parent)",
"acp.forums_tree": "Forenbaum",
"acp.forums_type": "Typ",
@@ -77,6 +78,9 @@
"form.password": "Passwort",
"form.post_reply": "Antwort posten",
"form.posting": "Wird gesendet...",
"form.preview": "Vorschau",
"form.upload": "Hochladen",
"form.uploading": "Wird hochgeladen...",
"form.registering": "Registrierung läuft...",
"form.reply_placeholder": "Schreibe deine Antwort.",
"form.sign_in": "Anmelden",
@@ -212,6 +216,63 @@
"ucp.accent_override": "Akzentfarbe überschreiben",
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
"ucp.custom_color": "Eigene Farbe",
"attachment.groups_title": "Anhanggruppen",
"attachment.group_create": "Neue Anhanggruppe",
"attachment.group_create_title": "Anhanggruppe erstellen",
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
"attachment.group_empty": "Noch keine Anhanggruppen.",
"attachment.seed_defaults": "Standard-Anhangset erstellen",
"attachment.seed_in_progress": "Standardwerte werden erstellt...",
"attachment.seed_hint": "Fügt eine Media- und Files-Struktur mit üblichen Endungen hinzu.",
"attachment.group_extensions": "{{count}} Endungen",
"attachment.group_delete_confirm": "Diese Anhanggruppe l\u00f6schen?",
"attachment.group_name": "Name",
"attachment.group_parent": "\u00dcbergeordnete Gruppe",
"attachment.group_parent_none": "Keine",
"attachment.group_max_size": "Max. Gr\u00f6\u00dfe (KB)",
"attachment.group_max_size_hint": "Standard 25600 KB (25 MB).",
"attachment.group_active": "Aktiv",
"attachment.group_add_child": "Untergruppe hinzuf\u00fcgen",
"attachment.group_auto_nest": "Standardgruppen automatisch verschachteln",
"attachment.group_auto_nest_hint": "Erstellt Media- und Files-Eltern und ordnet die Standardgruppen darunter ein.",
"attachment.extensions_title": "Dateiendungen verwalten",
"attachment.extension_placeholder": "Endung hinzuf\u00fcgen (z. B. pdf)",
"attachment.extension_mimes_placeholder": "Erlaubte MIME-Typen (kommagetrennt)",
"attachment.extension_unassigned": "Nicht zugewiesen",
"attachment.extension_add": "Endung hinzuf\u00fcgen",
"attachment.extension_edit": "Endung bearbeiten",
"attachment.extension_add_button": "Endung hinzuf\u00fcgen",
"attachment.extension_empty": "Noch keine Endungen.",
"attachment.extension": "Endung",
"attachment.extension_group": "Endungsgruppe",
"attachment.extension_delete_confirm": "Diese Endung l\u00f6schen?",
"attachment.actions": "Aktionen",
"attachment.allowed_mimes": "MIME-Typen:",
"attachment.active": "Aktiv",
"attachment.inactive": "Inaktiv",
"attachment.tab_options": "Optionen",
"attachment.tab_attachments": "Anh\u00e4nge",
"attachment.hint": "Wenn du Dateien anh\u00e4ngen m\u00f6chtest, f\u00fcge sie unten hinzu.",
"attachment.max_size": "Maximale Dateigr\u00f6\u00dfe pro Anhang: {{size}}.",
"attachment.add_files": "Dateien hinzuf\u00fcgen",
"attachment.drop_hint": "Sie k\u00f6nnen Dateien zum Hochladen hier hineinziehen oder",
"attachment.drop_browse": "Durchsuchen",
"attachment.filename": "Dateiname",
"attachment.size": "Gr\u00f6\u00dfe",
"attachment.status": "Status",
"attachment.empty": "Noch keine Dateien hinzugef\u00fcgt.",
"attachment.remove": "Datei entfernen",
"attachment.file_comment": "Dateikommentar",
"attachment.file_comment_placeholder": "Kommentar (optional)",
"attachment.place_inline": "Inline platzieren",
"attachment.delete_file": "Datei l\u00f6schen",
"attachment.option_disable_bbcode": "BBCode deaktivieren",
"attachment.option_disable_smilies": "Smileys deaktivieren",
"attachment.option_disable_auto_urls": "URLs nicht automatisch verlinken",
"attachment.option_attach_signature": "Signatur anh\u00e4ngen (über die UCP \u00e4nderbar)",
"attachment.option_notify_replies": "Bei Antworten benachrichtigen",
"attachment.option_lock_topic": "Thema sperren",
"attachment.invalid_extensions": "Nicht erlaubt: {{names}}.",
"thread.anonymous": "Anonym",
"thread.back_to_category": "Zurück zum Forum",
"thread.category": "Forum:",
@@ -230,6 +291,8 @@
"thread.solved": "Gel\u00f6st",
"thread.mark_solved": "Als gel\u00f6st markieren",
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
"thread.attachments": "Anh\u00e4nge",
"thread.attach_files": "Dateien anh\u00e4ngen",
"thread.views": "Zugriffe",
"thread.last_post": "Letzter Beitrag",
"thread.by": "von",

View File

@@ -46,6 +46,7 @@
"acp.add_forum": "Add forum",
"acp.ranks": "Ranks",
"acp.groups": "Groups",
"acp.attachments": "Attachments",
"acp.forums_parent_root": "Root (no parent)",
"acp.forums_tree": "Forum tree",
"acp.forums_type": "Type",
@@ -77,6 +78,9 @@
"form.password": "Password",
"form.post_reply": "Post reply",
"form.posting": "Posting...",
"form.preview": "Preview",
"form.upload": "Upload",
"form.uploading": "Uploading...",
"form.registering": "Registering...",
"form.reply_placeholder": "Share your reply.",
"form.sign_in": "Sign in",
@@ -212,6 +216,63 @@
"ucp.accent_override": "Accent color override",
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
"ucp.custom_color": "Custom color",
"attachment.groups_title": "Attachment groups",
"attachment.group_create": "New attachment group",
"attachment.group_create_title": "Create attachment group",
"attachment.group_edit_title": "Edit attachment group",
"attachment.group_empty": "No attachment groups yet.",
"attachment.seed_defaults": "Create default attachment set",
"attachment.seed_in_progress": "Creating defaults...",
"attachment.seed_hint": "Adds a Media and Files hierarchy with common extensions.",
"attachment.group_extensions": "{{count}} extensions",
"attachment.group_delete_confirm": "Delete this attachment group?",
"attachment.group_name": "Name",
"attachment.group_parent": "Parent group",
"attachment.group_parent_none": "No parent",
"attachment.group_max_size": "Max size (KB)",
"attachment.group_max_size_hint": "Default 25600 KB (25 MB).",
"attachment.group_active": "Active",
"attachment.group_add_child": "Add child group",
"attachment.group_auto_nest": "Auto-nest default groups",
"attachment.group_auto_nest_hint": "Creates Media and Files parents and nests the default groups underneath.",
"attachment.extensions_title": "Manage attachment extensions",
"attachment.extension_placeholder": "Add extension (e.g. pdf)",
"attachment.extension_mimes_placeholder": "Allowed MIME types (comma-separated)",
"attachment.extension_unassigned": "Not assigned",
"attachment.extension_add": "Add extension",
"attachment.extension_edit": "Edit extension",
"attachment.extension_add_button": "Add extension",
"attachment.extension_empty": "No extensions yet.",
"attachment.extension": "Extension",
"attachment.extension_group": "Extension group",
"attachment.extension_delete_confirm": "Delete this extension?",
"attachment.actions": "Actions",
"attachment.allowed_mimes": "MIME types:",
"attachment.active": "Active",
"attachment.inactive": "Inactive",
"attachment.tab_options": "Options",
"attachment.tab_attachments": "Attachments",
"attachment.hint": "If you wish to attach one or more files enter the details below.",
"attachment.max_size": "Maximum filesize per attachment: {{size}}.",
"attachment.add_files": "Add files",
"attachment.drop_hint": "Drag files here to upload or",
"attachment.drop_browse": "Browse",
"attachment.filename": "Filename",
"attachment.size": "Size",
"attachment.status": "Status",
"attachment.empty": "No files added yet.",
"attachment.remove": "Remove file",
"attachment.file_comment": "File comment",
"attachment.file_comment_placeholder": "Comment (optional)",
"attachment.place_inline": "Place inline",
"attachment.delete_file": "Delete file",
"attachment.option_disable_bbcode": "Disable BBCode",
"attachment.option_disable_smilies": "Disable smilies",
"attachment.option_disable_auto_urls": "Do not automatically parse URLs",
"attachment.option_attach_signature": "Attach a signature (signatures can be altered via the UCP)",
"attachment.option_notify_replies": "Notify me when a reply is posted",
"attachment.option_lock_topic": "Lock topic",
"attachment.invalid_extensions": "Not allowed: {{names}}.",
"thread.anonymous": "Anonymous",
"thread.back_to_category": "Back to forum",
"thread.category": "Forum:",
@@ -230,6 +291,8 @@
"thread.solved": "Solved",
"thread.mark_solved": "Mark solved",
"thread.mark_unsolved": "Mark unsolved",
"thread.attachments": "Attachments",
"thread.attach_files": "Attach files",
"thread.views": "Views",
"thread.last_post": "Last post",
"thread.by": "by",