Browse Source

Image upload

imwald
Nuša Pukšič 5 months ago
parent
commit
fc65870872
  1. 1
      assets/app.js
  2. 118
      assets/controllers/image_upload_controller.js
  3. 4
      assets/styles/form.css
  4. 33
      assets/styles/modal.css
  5. 1
      composer.json
  6. 173
      composer.lock
  7. 49
      src/Controller/ArticleController.php
  8. 139
      src/Controller/ImageUploadController.php
  9. 66
      src/Service/CacheService.php
  10. 40
      templates/pages/editor.html.twig

1
assets/app.js

@ -18,6 +18,7 @@ import './styles/notice.css';
import './styles/spinner.css'; import './styles/spinner.css';
import './styles/a2hs.css'; import './styles/a2hs.css';
import './styles/analytics.css'; import './styles/analytics.css';
import './styles/modal.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

118
assets/controllers/image_upload_controller.js

@ -0,0 +1,118 @@
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"];
// Unicode-safe base64 encoder
base64Encode(str) {
try {
return btoa(unescape(encodeURIComponent(str)));
} catch (_) {
return btoa(str);
}
}
openDialog() {
this.dialogTarget.style.display = '';
this.errorTarget.textContent = '';
this.progressTarget.style.display = 'none';
}
closeDialog() {
this.dialogTarget.style.display = 'none';
this.errorTarget.textContent = '';
this.progressTarget.style.display = 'none';
}
connect() {
this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click());
this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0]));
this.dropAreaTarget.addEventListener('dragover', (e) => {
e.preventDefault();
this.dropAreaTarget.classList.add('dragover');
});
this.dropAreaTarget.addEventListener('dragleave', (e) => {
e.preventDefault();
this.dropAreaTarget.classList.remove('dragover');
});
this.dropAreaTarget.addEventListener('drop', (e) => {
e.preventDefault();
this.dropAreaTarget.classList.remove('dragover');
if (e.dataTransfer.files.length > 0) {
this.handleFile(e.dataTransfer.files[0]);
}
});
}
async handleFile(file) {
if (!file) return;
this.errorTarget.textContent = '';
this.progressTarget.style.display = '';
this.progressTarget.textContent = 'Preparing upload...';
try {
// NIP98: get signed HTTP Auth event from window.nostr
if (!window.nostr || !window.nostr.signEvent) {
this.errorTarget.textContent = 'Nostr extension not found.';
return;
}
// Determine provider
const provider = this.providerTarget.value;
// Map provider -> upstream endpoint used for signing the NIP-98 event
const upstreamMap = {
nostrbuild: 'https://nostr.build/nip96/upload',
nostrcheck: 'https://nostrcheck.me/api/v2/media',
sovbit: 'https://files.sovbit.host/api/v2/media',
};
const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck'];
// Backend proxy endpoint to avoid third-party CORS
const proxyEndpoint = `/api/image-upload/${provider}`;
const event = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", upstreamEndpoint],
["method", "POST"]
],
content: ""
};
const signed = await window.nostr.signEvent(event);
const signedJson = JSON.stringify(signed);
const authHeader = 'Nostr ' + this.base64Encode(signedJson);
// Prepare form data
const formData = new FormData();
formData.append('uploadtype', 'media');
formData.append('file', file);
this.progressTarget.textContent = 'Uploading...';
// Upload to backend proxy
const response = await fetch(proxyEndpoint, {
method: 'POST',
headers: {
'Authorization': authHeader
},
body: formData
});
const result = await response.json().catch(() => ({}));
if (!response.ok || result.status !== 'success' || !result.url) {
this.errorTarget.textContent = result.message || `Upload failed (HTTP ${response.status})`;
return;
}
this.setImageField(result.url);
this.progressTarget.textContent = 'Upload successful!';
setTimeout(() => this.closeDialog(), 1000);
} catch (e) {
this.errorTarget.textContent = 'Upload error: ' + (e.message || e);
}
}
setImageField(url) {
// Find the image input in the form and set its value
const imageInput = document.querySelector('input[name$="[image]"]');
if (imageInput) {
imageInput.value = url;
imageInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}

4
assets/styles/form.css

@ -15,7 +15,7 @@ input {
clear: both; clear: both;
} }
input, textarea { input, textarea, select {
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
border: 2px solid var(--color-border); border: 2px solid var(--color-border);
@ -23,7 +23,7 @@ input, textarea {
border-radius: 0; /* Sharp edges */ border-radius: 0; /* Sharp edges */
} }
input:focus, textarea:focus { input:focus, textarea:focus, select:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
outline: none; outline: none;
} }

33
assets/styles/modal.css

@ -0,0 +1,33 @@
.iu-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.iu-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
width: min(600px, 90vw);
max-height: 85vh;
overflow: auto;
padding: 1rem 1.25rem;
z-index: 1000;
}
.iu-modal .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: .5rem;
margin-bottom: .5rem;
}
.iu-modal .close {
appearance: none;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}

1
composer.json

@ -38,6 +38,7 @@
"symfony/http-foundation": "7.1.*", "symfony/http-foundation": "7.1.*",
"symfony/intl": "7.1.*", "symfony/intl": "7.1.*",
"symfony/mercure-bundle": "^0.3.9", "symfony/mercure-bundle": "^0.3.9",
"symfony/mime": "7.1.*",
"symfony/property-access": "7.1.*", "symfony/property-access": "7.1.*",
"symfony/property-info": "7.1.*", "symfony/property-info": "7.1.*",
"symfony/runtime": "7.1.*", "symfony/runtime": "7.1.*",

173
composer.lock generated

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "b86dce5335224284e826d5e102c0bc65", "content-hash": "8172177fac811888db46cc398b805356",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -7218,6 +7218,90 @@
], ],
"time": "2024-05-31T09:07:18+00:00" "time": "2024-05-31T09:07:18+00:00"
}, },
{
"name": "symfony/mime",
"version": "v7.1.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "c252e20d1179dd35a5bfdb4a61a2084387ce97f4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/c252e20d1179dd35a5bfdb4a61a2084387ce97f4",
"reference": "c252e20d1179dd35a5bfdb4a61a2084387ce97f4",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0",
"symfony/mailer": "<6.4",
"symfony/serializer": "<6.4.3|>7.0,<7.0.3"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"symfony/dependency-injection": "^6.4|^7.0",
"symfony/process": "^6.4|^7.0",
"symfony/property-access": "^6.4|^7.0",
"symfony/property-info": "^6.4|^7.0",
"symfony/serializer": "^6.4.3|^7.0.3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.1.11"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-01-27T10:57:12+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v7.1.9", "version": "v7.1.9",
@ -7519,6 +7603,93 @@
], ],
"time": "2024-12-21T18:38:29+00:00" "time": "2024-12-21T18:38:29+00:00"
}, },
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-09-10T14:38:51+00:00"
},
{ {
"name": "symfony/polyfill-intl-normalizer", "name": "symfony/polyfill-intl-normalizer",
"version": "v1.32.0", "version": "v1.32.0",

49
src/Controller/ArticleController.php

@ -122,59 +122,33 @@ class ArticleController extends AbstractController
/** /**
* Create new article * Create new article
* @throws InvalidArgumentException
* @throws \Exception * @throws \Exception
*/ */
#[Route('/article-editor/create', name: 'editor-create')] #[Route('/article-editor/create', name: 'editor-create')]
#[Route('/article-editor/edit/{id}', name: 'editor-edit')] #[Route('/article-editor/edit/{id}', name: 'editor-edit')]
public function newArticle(Request $request, EntityManagerInterface $entityManager, CacheItemPoolInterface $articlesCache, public function newArticle(Request $request, EntityManagerInterface $entityManager, $id = null): Response
WorkflowInterface $articlePublishingWorkflow, Article $article = null): Response
{ {
if (!$article) { if (!$id) {
$article = new Article(); $article = new Article();
$article->setKind(KindsEnum::LONGFORM); $article->setKind(KindsEnum::LONGFORM);
$article->setCreatedAt(new \DateTimeImmutable()); $article->setCreatedAt(new \DateTimeImmutable());
$formAction = $this->generateUrl('editor-create'); $formAction = $this->generateUrl('editor-create');
} else { } else {
$formAction = $this->generateUrl('editor-edit', ['id' => $article->getId()]); $formAction = $this->generateUrl('editor-edit', ['id' => $id]);
$repository = $entityManager->getRepository(Article::class);
$article = $repository->find($id);
} }
$form = $this->createForm(EditorType::class, $article, ['action' => $formAction]); if ($article->getPubkey() === null) {
$form->handleRequest($request);
// Step 3: Check if the form is submitted and valid
if ($form->isSubmitted() && $form->isValid()) {
$user = $this->getUser(); $user = $this->getUser();
$key = new Key(); $key = new Key();
$currentPubkey = $key->convertToHex($user->getUserIdentifier()); $currentPubkey = $key->convertToHex($user->getUserIdentifier());
$article->setPubkey($currentPubkey);
if ($article->getPubkey() === null) {
$article->setPubkey($currentPubkey);
}
// Check which button was clicked
if ($form->getClickedButton() === $form->get('actions')->get('submit')) {
// Save button was clicked, handle the "Publish" action
$this->addFlash('success', 'Product published!');
} elseif ($form->getClickedButton() === $form->get('actions')->get('draft')) {
// Save and Publish button was clicked, handle the "Draft" action
$this->addFlash('success', 'Product saved as draft!');
} elseif ($form->getClickedButton() === $form->get('actions')->get('preview')) {
// Preview button was clicked, handle the "Preview" action
// construct slug from title and save to tags
$slugger = new AsciiSlugger();
$slug = $slugger->slug($article->getTitle())->lower();
$article->setSig(''); // clear the sig
$article->setSlug($slug);
$cacheKey = 'article_' . $currentPubkey . '_' . $article->getSlug();
$cacheItem = $articlesCache->getItem($cacheKey);
$cacheItem->set($article);
$articlesCache->save($cacheItem);
return $this->redirectToRoute('article-preview', ['d' => $article->getSlug()]);
}
} }
$form = $this->createForm(EditorType::class, $article, ['action' => $formAction]);
$form->handleRequest($request);
// load template with content editor // load template with content editor
return $this->render('pages/editor.html.twig', [ return $this->render('pages/editor.html.twig', [
'article' => $article, 'article' => $article,
@ -191,7 +165,6 @@ class ArticleController extends AbstractController
Request $request, Request $request,
EntityManagerInterface $entityManager, EntityManagerInterface $entityManager,
NostrClient $nostrClient, NostrClient $nostrClient,
WorkflowInterface $articlePublishingWorkflow,
CsrfTokenManagerInterface $csrfTokenManager CsrfTokenManagerInterface $csrfTokenManager
): JsonResponse { ): JsonResponse {
try { try {
@ -269,7 +242,7 @@ class ArticleController extends AbstractController
if ($user && method_exists($user, 'getRelays') && $user->getRelays()) { if ($user && method_exists($user, 'getRelays') && $user->getRelays()) {
foreach ($user->getRelays() as $relayArr) { foreach ($user->getRelays() as $relayArr) {
if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') { if (isset($relayArr[1]) && isset($relayArr[2]) && $relayArr[2] === 'write') {
// $relays[] = $relayArr[1]; $relays[] = $relayArr[1];
} }
} }
} }

139
src/Controller/ImageUploadController.php

@ -0,0 +1,139 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
class ImageUploadController extends AbstractController
{
#[Route('/api/image-upload/{provider}', name: 'api_image_upload', methods: ['POST'])]
public function proxyUpload(Request $request, string $provider): JsonResponse
{
$provider = strtolower($provider);
$endpoints = [
'nostrbuild' => 'https://nostr.build/nip96/upload',
'nostrcheck' => 'https://nostrcheck.me/api/v2/media',
'sovbit' => 'https://files.sovbit.host/api/v2/media',
];
if (!isset($endpoints[$provider])) {
return new JsonResponse(['status' => 'error', 'message' => 'Unsupported provider'], 400);
}
$authHeader = $request->headers->get('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Nostr ')) {
return new JsonResponse(['status' => 'error', 'message' => 'Missing or invalid Authorization header'], 401);
}
$file = $request->files->get('file');
if (!$file) {
return new JsonResponse(['status' => 'error', 'message' => 'Missing file'], 400);
}
$endpoint = $endpoints[$provider];
try {
$boundary = bin2hex(random_bytes(16));
$fields = [
'uploadtype' => 'media',
];
$body = '';
foreach ($fields as $name => $value) {
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n";
$body .= $value . "\r\n";
}
$fileContent = file_get_contents($file->getPathname());
if ($fileContent === false) {
return new JsonResponse(['status' => 'error', 'message' => 'Failed to read uploaded file'], 500);
}
$filename = $file->getClientOriginalName() ?: ('upload_' . date('Ymd_His'));
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
$body .= "--{$boundary}\r\n";
$body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
$body .= "Content-Type: {$mimeType}\r\n\r\n";
$body .= $fileContent . "\r\n";
$body .= "--{$boundary}--\r\n";
$headers = [
'Authorization: ' . $authHeader,
'Content-Type: multipart/form-data; boundary=' . $boundary,
];
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => implode("\r\n", $headers),
'content' => $body,
'ignore_errors' => true,
'timeout' => 30,
],
]);
$responseBody = @file_get_contents($endpoint, false, $context);
if ($responseBody === false) {
return new JsonResponse(['status' => 'error', 'message' => 'Upstream request failed'], 502);
}
$statusCode = 200;
if (isset($http_response_header) && is_array($http_response_header)) {
foreach ($http_response_header as $hdr) {
if (preg_match('#^HTTP/\\S+\\s+(\\d{3})#', $hdr, $m)) {
$statusCode = (int) $m[1];
break;
}
}
}
$json = json_decode($responseBody, true);
if (!is_array($json)) {
return new JsonResponse(['status' => 'error', 'message' => 'Invalid JSON from provider', 'raw' => $responseBody], 502);
}
$isSuccess = (($json['status'] ?? null) === 'success') || (($json['success'] ?? null) === true) || ($statusCode >= 200 && $statusCode < 300);
$imageUrl = null;
// Common locations: data.url, url, result.url, nip94_event.tags -> ['url', '...']
if (isset($json['data']['url']) && is_string($json['data']['url'])) {
$imageUrl = $json['data']['url'];
} elseif (isset($json['url']) && is_string($json['url'])) {
$imageUrl = $json['url'];
} elseif (isset($json['result']['url']) && is_string($json['result']['url'])) {
$imageUrl = $json['result']['url'];
}
if (!$imageUrl) {
$tags = $json['nip94_event']['tags'] ?? null;
if (is_array($tags)) {
foreach ($tags as $tag) {
if (is_array($tag) && ($tag[0] ?? null) === 'url' && isset($tag[1])) {
$imageUrl = $tag[1];
break;
}
}
}
}
if ($isSuccess && $imageUrl) {
return new JsonResponse(['status' => 'success', 'url' => $imageUrl]);
}
$message = $json['message'] ?? $json['error'] ?? $json['msg'] ?? 'Upload failed';
return new JsonResponse(['status' => 'error', 'message' => $message, 'provider' => $provider, 'raw' => $json], $statusCode >= 400 ? $statusCode : 502);
} catch (\Throwable $e) {
return new JsonResponse([
'status' => 'error',
'message' => 'Proxy error: ' . $e->getMessage(),
], 500);
}
}
}

66
src/Service/CacheService.php

@ -1,66 +0,0 @@
<?php
namespace App\Service;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
readonly class CacheService
{
public function __construct(
private NostrClient $nostrClient,
private CacheInterface $cache,
private LoggerInterface $logger
)
{
}
/**
* @param string $npub
* @return \stdClass
*/
public function getMetadata(string $npub): \stdClass
{
$cacheKey = '0_' . $npub;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub, $cacheKey) {
$item->expiresAfter(3600); // 1 hour, adjust as needed
try {
$meta = $this->nostrClient->getNpubMetadata($npub);
$this->logger->info('Metadata:', ['meta' => json_encode($meta)]);
return json_decode($meta->content);
} catch (\Exception $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
throw new MetadataRetrievalException('Failed to retrieve metadata', 0, $e);
}
});
} catch (\Exception|InvalidArgumentException $e) {
$this->logger->error('Error getting user data.', ['exception' => $e]);
$content = new \stdClass();
$content->name = substr($npub, 0, 8) . '…' . substr($npub, -4);
return $content;
}
}
public function getRelays($npub)
{
$cacheKey = '3_' . $npub;
try {
return $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour
try {
return $this->nostrClient->getNpubRelays($npub);
} catch (\Exception $e) {
$this->logger->error('Error getting relays.', ['exception' => $e]);
return [];
}
});
} catch (InvalidArgumentException $e) {
$this->logger->error('Error getting relay data.', ['exception' => $e]);
return [];
}
}
}

40
templates/pages/editor.html.twig

@ -27,6 +27,46 @@
{{ form_row(form.summary) }} {{ form_row(form.summary) }}
{{ form_row(form.content) }} {{ form_row(form.content) }}
{{ form_row(form.image) }} {{ form_row(form.image) }}
<div data-controller="image-upload">
<button type="button"
class="btn btn-secondary"
data-action="click->image-upload#openDialog">
Upload Image
</button>
<div data-image-upload-target="dialog" style="display:none;">
<div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div>
<div class="iu-modal">
<div class="modal-header">
<h5>Upload Image</h5>
<button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button>
</div>
<div class="modal-body">
<div style="margin-bottom:1em;">
<label for="upload-provider">Upload to</label>
<select id="upload-provider" data-image-upload-target="provider">
<option value="sovbit">files.sovbit.host</option>
<option value="nostrbuild">nostr.build</option>
<option value="nostrcheck">nostrcheck.me</option>
</select>
</div>
<div data-image-upload-target="dropArea"
class="upload-area"
style="border:2px dashed #ccc;padding:2em;text-align:center;cursor:pointer;min-height:4em;">
<span>Drag &amp; drop or click to select an image</span>
<input type="file" accept="image/*" style="display:none;" data-image-upload-target="fileInput">
</div>
<div data-image-upload-target="progress" style="display:none;margin-top:1em;"></div>
<div data-image-upload-target="error" style="color:red;margin-top:1em;"></div>
</div>
</div>
</div>
</div>
{{ form_row(form.topics) }} {{ form_row(form.topics) }}
<div class="actions"> <div class="actions">

Loading…
Cancel
Save