From be6a297b83cd62eb1c4732283da23e71d5272a14 Mon Sep 17 00:00:00 2001 From: trendschau Date: Sun, 5 Apr 2020 19:13:10 +0200 Subject: [PATCH] Version 1.3.4: Media Library --- .gitignore | 38 +- .htaccess | 80 +- media/markdown.png | Bin 1073 -> 0 bytes settings/languages/en.yaml | 4 + settings/languages/vuejs-en.yaml | 2 +- ...ontroller.php => ArticleApiController.php} | 618 +------------ system/Controllers/BlockApiController.php | 841 ++++++++++++++++++ system/Controllers/MediaApiController.php | 356 ++++++++ system/Controllers/MetaApiController.php | 22 +- system/Controllers/PageController.php | 22 +- system/Controllers/SettingsController.php | 84 +- system/Controllers/SetupController.php | 7 + system/Extensions/TwigPagelistExtension.php | 22 + system/Models/Folder.php | 23 +- system/Models/Helpers.php | 35 + system/Models/ProcessAssets.php | 231 +++++ system/Models/ProcessFile.php | 165 ++++ system/Models/ProcessImage.php | 393 ++++---- system/Routes/Api.php | 53 +- system/Routes/Web.php | 9 +- system/Settings.php | 60 +- system/author/css/style.css | 173 +++- system/author/js/author.js | 35 +- system/author/js/vue-blox-config.js | 8 + system/author/js/vue-blox.js | 812 ++++++++++++++++- system/author/layouts/layout.twig | 1 + system/author/layouts/layoutBlox.twig | 34 +- system/author/settings/system.twig | 31 +- system/system.php | 3 +- themes/typemill/cover.twig | 10 +- themes/typemill/css/style.css | 35 + .../typemill/img/apple-touch-icon-144x144.png | Bin 10865 -> 0 bytes .../typemill/img/apple-touch-icon-152x152.png | Bin 14560 -> 0 bytes themes/typemill/img/favicon-16x16.png | Bin 500 -> 0 bytes themes/typemill/img/favicon-32x32.png | Bin 1044 -> 0 bytes themes/typemill/img/favicon.ico | Bin 5430 -> 0 bytes themes/typemill/img/mstile-144x144.png | Bin 10865 -> 0 bytes themes/typemill/partials/layout.twig | 23 +- themes/typemill/partials/layoutCover.twig | 13 +- themes/typemill/typemill.yaml | 7 +- 40 files changed, 3271 insertions(+), 979 deletions(-) delete mode 100644 media/markdown.png rename system/Controllers/{ContentApiController.php => ArticleApiController.php} (58%) create mode 100644 system/Controllers/BlockApiController.php create mode 100644 system/Controllers/MediaApiController.php create mode 100644 system/Extensions/TwigPagelistExtension.php create mode 100644 system/Models/ProcessAssets.php create mode 100644 system/Models/ProcessFile.php delete mode 100644 themes/typemill/img/apple-touch-icon-144x144.png delete mode 100644 themes/typemill/img/apple-touch-icon-152x152.png delete mode 100644 themes/typemill/img/favicon-16x16.png delete mode 100644 themes/typemill/img/favicon-32x32.png delete mode 100644 themes/typemill/img/favicon.ico delete mode 100644 themes/typemill/img/mstile-144x144.png diff --git a/.gitignore b/.gitignore index 377c36b..86b8388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,20 @@ -cache/lastCache.txt -cache/lastSitemap.txt -cache/metatabs.yaml -cache/navigation.txt -cache/sitemap.xml -cache/structure-draft.txt -cache/structure-extended.yaml -cache/structure.txt -content/index.yaml -content/00-Welcome/index.yaml -content/00-Welcome/00-Setup.yaml -content/00-Welcome/01-Write-Content.yaml -content/00-Welcome/02-Get-Help.yaml -content/00-Welcome/03-Markdown-Test.yaml -settings/settings.yaml -settings/users -system/vendor -plugins/demo -zips +cache/lastCache.txt +cache/lastSitemap.txt +cache/metatabs.yaml +cache/navigation.txt +cache/sitemap.xml +cache/structure-draft.txt +cache/structure-extended.yaml +cache/structure.txt +content/index.yaml +content/00-welcome/index.yaml +content/00-welcome/00-setup.yaml +content/00-welcome/01-write-content.yaml +content/00-welcome/02-get-help.yaml +content/00-welcome/03-markdown-test.yaml +settings/settings.yaml +settings/users +system/vendor +plugins/demo +zips build.php \ No newline at end of file diff --git a/.htaccess b/.htaccess index 586f7af..92e92fa 100644 --- a/.htaccess +++ b/.htaccess @@ -1,41 +1,41 @@ -RewriteEngine On - -# If your homepage is http://yourdomain.com/yoursite -# Set the RewriteBase to: -# RewriteBase /yoursite - -# In some environements, an empty RewriteBase is required: -# RewriteBase / - -# Protect your system files from prying eyes -RewriteRule ^(system\/author\/) - [L] -RewriteRule ^(system) - [F,L] -RewriteRule ^(content) - [F,L] -RewriteRule ^(settings) - [F,L] -RewriteRule ^(.*)?\.yml$ - [F,L] -Rewriterule ^(.*)?\.yaml$ - [F,L] -RewriteRule ^(.*)?\.txt$ - [F,L] -RewriteRule ^(.*)?\.example$ - [F,L] -RewriteRule ^(.*/)?\.git+ - [F,L] - -# Use this to redirect HTTP to HTTPS on apache servers -# RewriteCond %{HTTPS} off -# RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] - -# Use this to redirect www to non-wwww on apache servers -# RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC] -# RewriteRule ^(.*)$ http://%1/$1 [R=301,L] - -# Use this to redirect slash/ to no slash urls on apache servers -# RewriteCond %{REQUEST_FILENAME} !-d -# RewriteRule ^(.*)/$ /$1 [R=301,L] - -# Removes index.php -RewriteCond %{THE_REQUEST} ^GET.*index\.php [NC] -RewriteRule (.*?)index\.php/*(.*) /$1$2 [R=301,NE,L] - -# Directs all web requests through the site index file -RewriteCond %{REQUEST_URI} !^/index\.php -RewriteCond %{REQUEST_FILENAME} !-f -RewriteCond %{REQUEST_FILENAME} !-d +RewriteEngine On + +# If your homepage is http://yourdomain.com/yoursite +# Set the RewriteBase to: +# RewriteBase /yoursite + +# In some environements, an empty RewriteBase is required: +# RewriteBase / + +# Protect your system files from prying eyes +RewriteRule ^(system\/author\/) - [L] +RewriteRule ^(system) - [F,L] +RewriteRule ^(content) - [F,L] +RewriteRule ^(settings) - [F,L] +RewriteRule ^(.*)?\.yml$ - [F,L] +Rewriterule ^(.*)?\.yaml$ - [F,L] +RewriteRule ^(.*)?\.txt$ - [F,L] +RewriteRule ^(.*)?\.example$ - [F,L] +RewriteRule ^(.*/)?\.git+ - [F,L] + +# Use this to redirect HTTP to HTTPS on apache servers +# RewriteCond %{HTTPS} off +# RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] + +# Use this to redirect www to non-wwww on apache servers +# RewriteCond %{HTTP_HOST} ^www\.(.*)$ [NC] +# RewriteRule ^(.*)$ http://%1/$1 [R=301,L] + +# Use this to redirect slash/ to no slash urls on apache servers +# RewriteCond %{REQUEST_FILENAME} !-d +# RewriteRule ^(.*)/$ /$1 [R=301,L] + +# Removes index.php +RewriteCond %{THE_REQUEST} ^GET.*index\.php [NC] +RewriteRule (.*?)index\.php/*(.*) /$1$2 [R=301,NE,L] + +# Directs all web requests through the site index file +RewriteCond %{REQUEST_URI} !^/index\.php +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L] \ No newline at end of file diff --git a/media/markdown.png b/media/markdown.png deleted file mode 100644 index 9470f0878f45a9a2cff72ff6fcbfd550daac338f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1073 zcmeAS@N?(olHy`uVBq!ia0vp^7l62dgBeKrei7dYq`CrpLR^9LAOXTm9Qr_~=#~We z1v9Yyd~!_M(vP`aV%n=e&u8XrO#7C6X^sIu&jEYRlioIG4zDN;YvGZ1xiA})M+sipGn)>Uzi2f0< zawwCo6Iht@KcDlaZhVK)8&i({E)z$|DM(0V1CP=1WgNGz$TXKfi%1FB+1GIOw2{K< zEe~UNCs`@1p7tQ}Wr~-A^nuNdnTHg>lot1CyA3PIru9OTBQX$!t1ftLm<(NsJ4xeXssydZNK; z-x*E$1uTbduuC~}v-nFqOVp1#&b9t?qQKz?d`=Q8EVW)qn%V>HzT?QjoM@{3Y^Lr< zkQ|Sb2FF$}LI0&~{y^7+3mkHc@VWQhN34S(NkYbvgMHz}>g$)Y6_`2_3)mE8S|09R zps3UU^39(Ghd#bj;5#APRwVmS;n?rL3C9@|-|#tEbbRFFFY^(2_D-RtS>pYNgySrR zJUmVo8aDTM%2GhyZfQRB_-w*)W{_~AK-D~uj>m>}3`u)z5BBjNYL_d2!M{z6 zL$-dyR)?PL);Ee2j@_2cu@!jymT#LJ$9|tX2dA+b-s9P3)&VqTtK*9u&1<=n_DJTW z3Ou%r1_pVB@r_Fg$7UZ4V*{!xllmWGA>$rB}@w|>6NxNG+0+CLx_l+`Z(`1%xw{maT4ewZCqz9($evVZC;Q_hz$DF^?SO@Dvz u{g1@k-QQ9Trt4aK*d^zBrvLiaKb#L%vFaWBxB4$A<9oXLxvXwithJson(array('data' => $content, 'errors' => false)); } - - public function addBlock(Request $request, Response $response, $args) - { - /* get params from call */ - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - - /* validate input */ - if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } - - # set structure - if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - /* set item */ - if(!$this->setItem()){ return $response->withJson($this->errors, 404); } - - # set the status for published and drafted - $this->setPublishStatus(); - - # set path - $this->setItemPath($this->item->fileType); - - # read content from file - if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # make it more clear which content we have - $pageMarkdown = $this->content; - - $blockMarkdown = $this->params['markdown']; - - # standardize line breaks - $blockMarkdown = str_replace(array("\r\n", "\r"), "\n", $blockMarkdown); - - # remove surrounding line breaks - $blockMarkdown = trim($blockMarkdown, "\n"); - - if($pageMarkdown == '') - { - $pageMarkdown = []; - } - - # initialize parsedown extension - $parsedown = new ParsedownExtension(); - - # if content is not an array, then transform it - if(!is_array($pageMarkdown)) - { - # turn markdown into an array of markdown-blocks - $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); - } - - # if it is a new content-block - if($this->params['block_id'] == 99999) - { - # set the id of the markdown-block (it will be one more than the actual array, so count is perfect) - $id = count($pageMarkdown); - - # add the new markdown block to the page content - $pageMarkdown[] = $blockMarkdown; - } - elseif(($this->params['block_id'] == 0) OR !isset($pageMarkdown[$this->params['block_id']])) - { - # if the block does not exists, return an error - return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); - } - else - { - # insert new markdown block - array_splice( $pageMarkdown, $this->params['block_id'], 0, $blockMarkdown ); - $id = $this->params['block_id']; - } - - # encode the content into json - $pageJson = json_encode($pageMarkdown); - - # set path for the file (or folder) - $this->setItemPath('txt'); - - /* update the file */ - if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) - { - # update the internal structure - $this->setStructure($draft = true, $cache = false); - $this->content = $pageMarkdown; - } - else - { - return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); - } - - /* set safe mode to escape javascript and html in markdown */ - $parsedown->setSafeMode(true); - - /* parse markdown-file to content-array */ - $blockArray = $parsedown->text($blockMarkdown); - - # we assume that toc is not relevant - $toc = false; - - # needed for ToC links - $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; - - if($blockMarkdown == '[TOC]') - { - # if block is table of content itself, then generate the table of content - $tableofcontent = $this->generateToc(); - - # and only use the html-markup - $blockHTML = $tableofcontent['html']; - } - else - { - # parse markdown-content-array to content-string - $blockHTML = $parsedown->markup($blockArray, $relurl); - - # if it is a headline - if($blockMarkdown[0] == '#') - { - # then the TOC holds either false (if no toc used in the page) or it holds an object with the id and toc-markup - $toc = $this->generateToc(); - } - } - - return $response->withJson(array('content' => [ 'id' => $id, 'html' => $blockHTML ] , 'markdown' => $blockMarkdown, 'id' => $id, 'toc' => $toc, 'errors' => false)); - } - - protected function generateToc() - { - # we assume that page has no table of content - $toc = false; - - # make sure $this->content is updated - $content = $this->content; - - if($content == '') - { - $content = []; - } - - # initialize parsedown extension - $parsedown = new ParsedownExtension(); - - # if content is not an array, then transform it - if(!is_array($content)) - { - # turn markdown into an array of markdown-blocks - $content = $parsedown->markdownToArrayBlocks($content); - } - - # needed for ToC links - $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; - - # loop through mardkown-array and create html-blocks - foreach($content as $key => $block) - { - # parse markdown-file to content-array - $contentArray = $parsedown->text($block); - - if($block == '[TOC]') - { - # toc is true and holds the key of the table of content now - $toc = $key; - } - - # parse markdown-content-array to content-string - $content[$key] = ['id' => $key, 'html' => $parsedown->markup($contentArray, $relurl)]; - } - - # if page has a table of content - if($toc) - { - # generate the toc markup - $tocMarkup = $parsedown->buildTOC($parsedown->headlines); - - # toc holds the id of the table of content and the html-markup now - $toc = ['id' => $toc, 'html' => $tocMarkup]; - } - - return $toc; - } - - public function updateBlock(Request $request, Response $response, $args) - { - /* get params from call */ - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - - /* validate input */ - if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } - - # set structure - if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - /* set item */ - if(!$this->setItem()){ return $response->withJson($this->errors, 404); } - - # set the status for published and drafted - $this->setPublishStatus(); - - # set path - $this->setItemPath($this->item->fileType); - - # read content from file - if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # make it more clear which content we have - $pageMarkdown = $this->content; - - $blockMarkdown = $this->params['markdown']; - - # standardize line breaks - $blockMarkdown = str_replace(array("\r\n", "\r"), "\n", $blockMarkdown); - - # remove surrounding line breaks - $blockMarkdown = trim($blockMarkdown, "\n"); - - if($pageMarkdown == '') - { - $pageMarkdown = []; - } - - # initialize parsedown extension - $parsedown = new ParsedownExtension(); - $parsedown->setVisualMode(); - - # if content is not an array, then transform it - if(!is_array($pageMarkdown)) - { - # turn markdown into an array of markdown-blocks - $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); - } - - if(!isset($pageMarkdown[$this->params['block_id']])) - { - # if the block does not exists, return an error - return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); - } - elseif($this->params['block_id'] == 0) - { - # if it is the title, then delete the "# " if it exists - $blockMarkdown = trim($blockMarkdown, "# "); - - # store the markdown-headline in a separate variable - $blockMarkdownTitle = '# ' . $blockMarkdown; - - # add the markdown-headline to the page-markdown - $pageMarkdown[0] = $blockMarkdownTitle; - $id = 0; - } - else - { - # update the markdown block in the page content - $pageMarkdown[$this->params['block_id']] = $blockMarkdown; - $id = $this->params['block_id']; - } - - # encode the content into json - $pageJson = json_encode($pageMarkdown); - - # set path for the file (or folder) - $this->setItemPath('txt'); - - /* update the file */ - if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) - { - # update the internal structure - $this->setStructure($draft = true, $cache = false); - - # updated the content variable - $this->content = $pageMarkdown; - } - else - { - return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); - } - - - /* parse markdown-file to content-array, if title parse title. */ - if($this->params['block_id'] == 0) - { - $blockArray = $parsedown->text($blockMarkdownTitle); - } - else - { - /* set safe mode to escape javascript and html in markdown */ - $parsedown->setSafeMode(true); - - $blockArray = $parsedown->text($blockMarkdown); - } - - # we assume that toc is not relevant - $toc = false; - - # needed for ToC links - $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; - - if($blockMarkdown == '[TOC]') - { - # if block is table of content itself, then generate the table of content - $tableofcontent = $this->generateToc(); - - # and only use the html-markup - $blockHTML = $tableofcontent['html']; - } - else - { - # parse markdown-content-array to content-string - $blockHTML = $parsedown->markup($blockArray, $relurl); - - # if it is a headline - if($blockMarkdown[0] == '#') - { - # then the TOC holds either false (if no toc used in the page) or it holds an object with the id and toc-markup - $toc = $this->generateToc(); - } - } - - return $response->withJson(array('content' => [ 'id' => $id, 'html' => $blockHTML ] , 'markdown' => $blockMarkdown, 'id' => $id, 'toc' => $toc, 'errors' => false)); - } - - public function moveBlock(Request $request, Response $response, $args) - { - # get params from call - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - - # validate input - # if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } - - # set structure - if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # set item - if(!$this->setItem()){ return $response->withJson($this->errors, 404); } - - # set the status for published and drafted - $this->setPublishStatus(); - - # set path - $this->setItemPath($this->item->fileType); - - # read content from file - if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # make it more clear which content we have - $pageMarkdown = $this->content; - - if($pageMarkdown == '') - { - $pageMarkdown = []; - } - - # initialize parsedown extension - $parsedown = new ParsedownExtension(); - - # if content is not an array, then transform it - if(!is_array($pageMarkdown)) - { - # turn markdown into an array of markdown-blocks - $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); - } - - $oldIndex = ($this->params['old_index'] + 1); - $newIndex = ($this->params['new_index'] + 1); - - if(!isset($pageMarkdown[$oldIndex])) - { - # if the block does not exists, return an error - return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); - } - - $extract = array_splice($pageMarkdown, $oldIndex, 1); - array_splice($pageMarkdown, $newIndex, 0, $extract); - - # encode the content into json - $pageJson = json_encode($pageMarkdown); - - # set path for the file (or folder) - $this->setItemPath('txt'); - - /* update the file */ - if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) - { - # update the internal structure - $this->setStructure($draft = true, $cache = false); - - # update this content - $this->content = $pageMarkdown; - } - else - { - return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); - } - - # we assume that toc is not relevant - $toc = false; - - # needed for ToC links - $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; - - # if the moved item is a headline - if($extract[0][0] == '#') - { - $toc = $this->generateToc(); - } - - # if it is the title, then delete the "# " if it exists - $pageMarkdown[0] = trim($pageMarkdown[0], "# "); - - return $response->withJson(array('markdown' => $pageMarkdown, 'toc' => $toc, 'errors' => false)); - } - - public function deleteBlock(Request $request, Response $response, $args) - { - /* get params from call */ - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - $errors = false; - - # set structure - if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # set item - if(!$this->setItem()){ return $response->withJson($this->errors, 404); } - - # set the status for published and drafted - $this->setPublishStatus(); - - # set path - $this->setItemPath($this->item->fileType); - - # read content from file - if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } - - # get content - $this->content; - - if($this->content == '') - { - $this->content = []; - } - - # initialize parsedown extension - $parsedown = new ParsedownExtension(); - - # if content is not an array, then transform it - if(!is_array($this->content)) - { - # turn markdown into an array of markdown-blocks - $this->content = $parsedown->markdownToArrayBlocks($this->content); - } - - # check if id exists - if(!isset($this->content[$this->params['block_id']])){ return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); } - - # check if block is image - $contentBlock = $this->content[$this->params['block_id']]; - $contentBlockStart = substr($contentBlock, 0, 2); - if($contentBlockStart == '[!' OR $contentBlockStart == '![') - { - # extract image path - preg_match("/\((.*?)\)/",$contentBlock,$matches); - if(isset($matches[1])) - { - $imageBaseName = explode('-', $matches[1]); - $imageBaseName = str_replace('media/live/', '', $imageBaseName[0]); - $processImage = new ProcessImage(); - if(!$processImage->deleteImage($imageBaseName)) - { - $errors = 'Could not delete some of the images, please check manually'; - } - } - } - - # delete the block - unset($this->content[$this->params['block_id']]); - $this->content = array_values($this->content); - - $pageMarkdown = $this->content; - - # delete markdown from title - if(isset($pageMarkdown[0])) - { - $pageMarkdown[0] = trim($pageMarkdown[0], "# "); - } - - # encode the content into json - $pageJson = json_encode($this->content); - - # set path for the file (or folder) - $this->setItemPath('txt'); - - /* update the file */ - if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) - { - # update the internal structure - $this->setStructure($draft = true, $cache = false); - } - else - { - return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); - } - - $toc = false; - - if($contentBlock[0] == '#') - { - $toc = $this->generateToc(); - } - - return $response->withJson(array('markdown' => $pageMarkdown, 'toc' => $toc, 'errors' => $errors)); - } - - public function createImage(Request $request, Response $response, $args) - { - /* get params from call */ - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - - $imageProcessor = new ProcessImage(); - - if($imageProcessor->createImage($this->params['image'], $this->settings['images'])) - { - return $response->withJson(array('errors' => false)); - } - - return $response->withJson(array('errors' => 'could not store image to temporary folder')); - } - - public function publishImage(Request $request, Response $response, $args) - { - $params = $request->getParsedBody(); - - $imageProcessor = new ProcessImage(); - - $imageUrl = $imageProcessor->publishImage($this->settings['images'], $name = false); - if($imageUrl) - { - $params['markdown'] = str_replace('imgplchldr', $imageUrl, $params['markdown']); - - $request = $request->withParsedBody($params); - - return $this->addBlock($request, $response, $args); - } - - return $response->withJson(array('errors' => 'could not store image to media folder')); - } - - public function saveVideoImage(Request $request, Response $response, $args) - { - /* get params from call */ - $this->params = $request->getParams(); - $this->uri = $request->getUri(); - $class = false; - - $imageUrl = $this->params['markdown']; - - if(strpos($imageUrl, 'https://www.youtube.com/watch?v=') !== false) - { - $videoID = str_replace('https://www.youtube.com/watch?v=', '', $imageUrl); - $videoID = strpos($videoID, '&') ? substr($videoID, 0, strpos($videoID, '&')) : $videoID; - $class = 'youtube'; - } - if(strpos($imageUrl, 'https://youtu.be/') !== false) - { - $videoID = str_replace('https://youtu.be/', '', $imageUrl); - $videoID = strpos($videoID, '?') ? substr($videoID, 0, strpos($videoID, '?')) : $videoID; - $class = 'youtube'; - } - - if($class == 'youtube') - { - $videoURLmaxres = 'https://i1.ytimg.com/vi/' . $videoID . '/maxresdefault.jpg'; - $videoURL0 = 'https://i1.ytimg.com/vi/' . $videoID . '/0.jpg'; - } - - $ctx = stream_context_create(array( - 'https' => array( - 'timeout' => 1 - ) - ) - ); - - $imageData = @file_get_contents($videoURLmaxres, 0, $ctx); - if($imageData === false) - { - $imageData = @file_get_contents($videoURL0, 0, $ctx); - if($imageData === false) - { - return $response->withJson(array('errors' => 'could not get the video image')); - } - } - - $imageData64 = 'data:image/jpeg;base64,' . base64_encode($imageData); - $desiredSizes = ['live' => ['width' => 560, 'height' => 315]]; - $imageProcessor = new ProcessImage(); - $tmpImage = $imageProcessor->createImage($imageData64, $desiredSizes); - - if(!$tmpImage) - { - return $response->withJson(array('errors' => 'could not create temporary image')); - } - - $imageUrl = $imageProcessor->publishImage($desiredSizes, $videoID); - if($imageUrl) - { - $this->params['markdown'] = '![' . $class . '-video](' . $imageUrl . ' "click to load video"){#' . $videoID. ' .' . $class . '}'; - - $request = $request->withParsedBody($this->params); - - return $this->addBlock($request, $response, $args); - } - - return $response->withJson(array('errors' => 'could not store the preview image')); - } } \ No newline at end of file diff --git a/system/Controllers/BlockApiController.php b/system/Controllers/BlockApiController.php new file mode 100644 index 0000000..9c77ff4 --- /dev/null +++ b/system/Controllers/BlockApiController.php @@ -0,0 +1,841 @@ +params = $request->getParams(); + $this->uri = $request->getUri(); + + /* validate input */ + if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } + + # set structure + if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + /* set item */ + if(!$this->setItem()){ return $response->withJson($this->errors, 404); } + + # set the status for published and drafted + $this->setPublishStatus(); + + # set path + $this->setItemPath($this->item->fileType); + + # read content from file + if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # make it more clear which content we have + $pageMarkdown = $this->content; + + $blockMarkdown = $this->params['markdown']; + + # standardize line breaks + $blockMarkdown = str_replace(array("\r\n", "\r"), "\n", $blockMarkdown); + + # remove surrounding line breaks + $blockMarkdown = trim($blockMarkdown, "\n"); + + if($pageMarkdown == '') + { + $pageMarkdown = []; + } + + # initialize parsedown extension + $parsedown = new ParsedownExtension(); + + # if content is not an array, then transform it + if(!is_array($pageMarkdown)) + { + # turn markdown into an array of markdown-blocks + $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); + } + + # if it is a new content-block + if($this->params['block_id'] == 99999) + { + # set the id of the markdown-block (it will be one more than the actual array, so count is perfect) + $id = count($pageMarkdown); + + # add the new markdown block to the page content + $pageMarkdown[] = $blockMarkdown; + } + elseif(($this->params['block_id'] == 0) OR !isset($pageMarkdown[$this->params['block_id']])) + { + # if the block does not exists, return an error + return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); + } + else + { + # insert new markdown block + array_splice( $pageMarkdown, $this->params['block_id'], 0, $blockMarkdown ); + $id = $this->params['block_id']; + } + + # encode the content into json + $pageJson = json_encode($pageMarkdown); + + # set path for the file (or folder) + $this->setItemPath('txt'); + + /* update the file */ + if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) + { + # update the internal structure + $this->setStructure($draft = true, $cache = false); + $this->content = $pageMarkdown; + } + else + { + return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); + } + + /* set safe mode to escape javascript and html in markdown */ + $parsedown->setSafeMode(true); + + /* parse markdown-file to content-array */ + $blockArray = $parsedown->text($blockMarkdown); + + # we assume that toc is not relevant + $toc = false; + + # needed for ToC links + $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; + + if($blockMarkdown == '[TOC]') + { + # if block is table of content itself, then generate the table of content + $tableofcontent = $this->generateToc(); + + # and only use the html-markup + $blockHTML = $tableofcontent['html']; + } + else + { + # parse markdown-content-array to content-string + $blockHTML = $parsedown->markup($blockArray, $relurl); + + # if it is a headline + if($blockMarkdown[0] == '#') + { + # then the TOC holds either false (if no toc used in the page) or it holds an object with the id and toc-markup + $toc = $this->generateToc(); + } + } + + return $response->withJson(array('content' => [ 'id' => $id, 'html' => $blockHTML ] , 'markdown' => $blockMarkdown, 'id' => $id, 'toc' => $toc, 'errors' => false)); + } + + protected function generateToc() + { + # we assume that page has no table of content + $toc = false; + + # make sure $this->content is updated + $content = $this->content; + + if($content == '') + { + $content = []; + } + + # initialize parsedown extension + $parsedown = new ParsedownExtension(); + + # if content is not an array, then transform it + if(!is_array($content)) + { + # turn markdown into an array of markdown-blocks + $content = $parsedown->markdownToArrayBlocks($content); + } + + # needed for ToC links + $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; + + # loop through mardkown-array and create html-blocks + foreach($content as $key => $block) + { + # parse markdown-file to content-array + $contentArray = $parsedown->text($block); + + if($block == '[TOC]') + { + # toc is true and holds the key of the table of content now + $toc = $key; + } + + # parse markdown-content-array to content-string + $content[$key] = ['id' => $key, 'html' => $parsedown->markup($contentArray, $relurl)]; + } + + # if page has a table of content + if($toc) + { + # generate the toc markup + $tocMarkup = $parsedown->buildTOC($parsedown->headlines); + + # toc holds the id of the table of content and the html-markup now + $toc = ['id' => $toc, 'html' => $tocMarkup]; + } + + return $toc; + } + + public function updateBlock(Request $request, Response $response, $args) + { + /* get params from call */ + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + /* validate input */ + if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } + + # set structure + if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + /* set item */ + if(!$this->setItem()){ return $response->withJson($this->errors, 404); } + + # set the status for published and drafted + $this->setPublishStatus(); + + # set path + $this->setItemPath($this->item->fileType); + + # read content from file + if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # make it more clear which content we have + $pageMarkdown = $this->content; + + $blockMarkdown = $this->params['markdown']; + + # standardize line breaks + $blockMarkdown = str_replace(array("\r\n", "\r"), "\n", $blockMarkdown); + + # remove surrounding line breaks + $blockMarkdown = trim($blockMarkdown, "\n"); + + if($pageMarkdown == '') + { + $pageMarkdown = []; + } + + # initialize parsedown extension + $parsedown = new ParsedownExtension(); + $parsedown->setVisualMode(); + + # if content is not an array, then transform it + if(!is_array($pageMarkdown)) + { + # turn markdown into an array of markdown-blocks + $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); + } + + if(!isset($pageMarkdown[$this->params['block_id']])) + { + # if the block does not exists, return an error + return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); + } + elseif($this->params['block_id'] == 0) + { + # if it is the title, then delete the "# " if it exists + $blockMarkdown = trim($blockMarkdown, "# "); + + # store the markdown-headline in a separate variable + $blockMarkdownTitle = '# ' . $blockMarkdown; + + # add the markdown-headline to the page-markdown + $pageMarkdown[0] = $blockMarkdownTitle; + $id = 0; + } + else + { + # update the markdown block in the page content + $pageMarkdown[$this->params['block_id']] = $blockMarkdown; + $id = $this->params['block_id']; + } + + # encode the content into json + $pageJson = json_encode($pageMarkdown); + + # set path for the file (or folder) + $this->setItemPath('txt'); + + /* update the file */ + if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) + { + # update the internal structure + $this->setStructure($draft = true, $cache = false); + + # updated the content variable + $this->content = $pageMarkdown; + } + else + { + return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); + } + + + /* parse markdown-file to content-array, if title parse title. */ + if($this->params['block_id'] == 0) + { + $blockArray = $parsedown->text($blockMarkdownTitle); + } + else + { + /* set safe mode to escape javascript and html in markdown */ + $parsedown->setSafeMode(true); + + $blockArray = $parsedown->text($blockMarkdown); + } + + # we assume that toc is not relevant + $toc = false; + + # needed for ToC links + $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; + + if($blockMarkdown == '[TOC]') + { + # if block is table of content itself, then generate the table of content + $tableofcontent = $this->generateToc(); + + # and only use the html-markup + $blockHTML = $tableofcontent['html']; + } + else + { + # parse markdown-content-array to content-string + $blockHTML = $parsedown->markup($blockArray, $relurl); + + # if it is a headline + if($blockMarkdown[0] == '#') + { + # then the TOC holds either false (if no toc used in the page) or it holds an object with the id and toc-markup + $toc = $this->generateToc(); + } + } + + return $response->withJson(array('content' => [ 'id' => $id, 'html' => $blockHTML ] , 'markdown' => $blockMarkdown, 'id' => $id, 'toc' => $toc, 'errors' => false)); + } + + public function moveBlock(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + # validate input + # if(!$this->validateBlockInput()){ return $response->withJson($this->errors,422); } + + # set structure + if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # set item + if(!$this->setItem()){ return $response->withJson($this->errors, 404); } + + # set the status for published and drafted + $this->setPublishStatus(); + + # set path + $this->setItemPath($this->item->fileType); + + # read content from file + if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # make it more clear which content we have + $pageMarkdown = $this->content; + + if($pageMarkdown == '') + { + $pageMarkdown = []; + } + + # initialize parsedown extension + $parsedown = new ParsedownExtension(); + + # if content is not an array, then transform it + if(!is_array($pageMarkdown)) + { + # turn markdown into an array of markdown-blocks + $pageMarkdown = $parsedown->markdownToArrayBlocks($pageMarkdown); + } + + $oldIndex = ($this->params['old_index'] + 1); + $newIndex = ($this->params['new_index'] + 1); + + if(!isset($pageMarkdown[$oldIndex])) + { + # if the block does not exists, return an error + return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); + } + + $extract = array_splice($pageMarkdown, $oldIndex, 1); + array_splice($pageMarkdown, $newIndex, 0, $extract); + + # encode the content into json + $pageJson = json_encode($pageMarkdown); + + # set path for the file (or folder) + $this->setItemPath('txt'); + + /* update the file */ + if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) + { + # update the internal structure + $this->setStructure($draft = true, $cache = false); + + # update this content + $this->content = $pageMarkdown; + } + else + { + return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); + } + + # we assume that toc is not relevant + $toc = false; + + # needed for ToC links + $relurl = '/tm/content/' . $this->settings['editor'] . '/' . $this->item->urlRel; + + # if the moved item is a headline + if($extract[0][0] == '#') + { + $toc = $this->generateToc(); + } + + # if it is the title, then delete the "# " if it exists + $pageMarkdown[0] = trim($pageMarkdown[0], "# "); + + return $response->withJson(array('markdown' => $pageMarkdown, 'toc' => $toc, 'errors' => false)); + } + + public function deleteBlock(Request $request, Response $response, $args) + { + /* get params from call */ + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + $errors = false; + + # set structure + if(!$this->setStructure($draft = true)){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # set item + if(!$this->setItem()){ return $response->withJson($this->errors, 404); } + + # set the status for published and drafted + $this->setPublishStatus(); + + # set path + $this->setItemPath($this->item->fileType); + + # read content from file + if(!$this->setContent()){ return $response->withJson(array('data' => false, 'errors' => $this->errors), 404); } + + # get content + $this->content; + + if($this->content == '') + { + $this->content = []; + } + + # initialize parsedown extension + $parsedown = new ParsedownExtension(); + + # if content is not an array, then transform it + if(!is_array($this->content)) + { + # turn markdown into an array of markdown-blocks + $this->content = $parsedown->markdownToArrayBlocks($this->content); + } + + # check if id exists + if(!isset($this->content[$this->params['block_id']])){ return $response->withJson(array('data' => false, 'errors' => 'The ID of the content-block is wrong.'), 404); } + + $contentBlock = $this->content[$this->params['block_id']]; + + # delete the block + unset($this->content[$this->params['block_id']]); + $this->content = array_values($this->content); + + $pageMarkdown = $this->content; + + # delete markdown from title + if(isset($pageMarkdown[0])) + { + $pageMarkdown[0] = trim($pageMarkdown[0], "# "); + } + + # encode the content into json + $pageJson = json_encode($this->content); + + # set path for the file (or folder) + $this->setItemPath('txt'); + + /* update the file */ + if($this->write->writeFile($this->settings['contentFolder'], $this->path, $pageJson)) + { + # update the internal structure + $this->setStructure($draft = true, $cache = false); + } + else + { + return $response->withJson(['errors' => ['message' => 'Could not write to file. Please check if the file is writable']], 404); + } + + $toc = false; + + if($contentBlock[0] == '#') + { + $toc = $this->generateToc(); + } + + return $response->withJson(array('markdown' => $pageMarkdown, 'toc' => $toc, 'errors' => $errors)); + } + + public function getMediaLibImages(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $imagelist = $imageProcessor->scanMediaFlat(); + + return $response->withJson(array('images' => $imagelist)); + } + + public function getMediaLibFiles(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $filelist = $fileProcessor->scanFilesFlat(); + + return $response->withJson(array('files' => $filelist)); + } + + public function getImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $this->setStructure($draft = true, $cache = false); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $imageDetails = $imageProcessor->getImageDetails($this->params['name'], $this->structure); + + if($imageDetails) + { + return $response->withJson(array('image' => $imageDetails)); + } + + # return $response->withJson(array('image' => false, 'errors' => 'image name invalid or not found')); + return $response->withJson(['errors' => ['message' => 'Image name invalid or not found.']], 404); + } + + public function getFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $this->setStructure($draft = true, $cache = false); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $fileDetails = $fileProcessor->getFileDetails($this->params['name'], $this->structure); + + if($fileDetails) + { + return $response->withJson(['file' => $fileDetails]); + } + + return $response->withJson(['errors' => ['message' => 'file name invalid or not found']],404); + } + + public function createImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + # do this shit in the model ... + $imagename = explode('.', $this->params['name']); + array_pop($imagename); + $imagename = implode('-', $imagename); + $name = URLify::filter(iconv(mb_detect_encoding($imagename, mb_detect_order(), true), "UTF-8", $imagename)); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + if($imageProcessor->createImage($this->params['image'], $name, $this->settings['images'])) + { + return $response->withJson(array('errors' => false)); + } + + return $response->withJson(array('errors' => 'could not store image to temporary folder')); + } + + public function createFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + $mtype = finfo_file( $finfo, $this->params['file'] ); + finfo_close( $finfo ); + + $allowedMimes = $this->getAllowedMtypes(); + if(!in_array($mtype, $allowedMimes)) + { + return $response->withJson(array('errors' => 'File-type is not allowed')); + } + + # sanitize file name + $filename = basename($this->params['name']); + $filename = explode('.', $this->params['name']); + array_pop($filename); + $filename = implode('-', $filename); + $name = URLify::filter(iconv(mb_detect_encoding($filename, mb_detect_order(), true), "UTF-8", $filename)); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + if($fileProcessor->createFile($this->params['file'], $name)) + { + return $response->withJson(array('errors' => false, 'name' => $name)); + } + + return $response->withJson(array('errors' => 'could not store file to temporary folder')); + } + + public function publishImage(Request $request, Response $response, $args) + { + $params = $request->getParsedBody(); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $imageUrl = $imageProcessor->publishImage(); + if($imageUrl) + { + # replace the image placeholder in markdown with the image url + $params['markdown'] = str_replace('imgplchldr', $imageUrl, $params['markdown']); + + $request = $request->withParsedBody($params); + + if($params['new']) + { + return $this->addBlock($request, $response, $args); + } + return $this->updateBlock($request, $response, $args); + } + + return $response->withJson(array('errors' => 'could not store image to media folder')); + } + + public function deleteImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + if(!isset($this->params['name'])) + { + return $response->withJson(array('errors' => 'image name is missing')); + } + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $errors = $imageProcessor->deleteImage($this->params['name']); + + return $response->withJson(array('errors' => $errors)); + } + + public function deleteFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + if(!isset($this->params['name'])) + { + return $response->withJson(array('errors' => 'file name is missing')); + } + + $fileProcessor = new ProcessFile(); + + $errors = false; + if($fileProcessor->deleteFile($this->params['name'])) + { + return $response->withJson(array('errors' => false)); + } + + return $response->withJson(array('errors' => 'could not delete the file')); + } + + public function saveVideoImage(Request $request, Response $response, $args) + { + /* get params from call */ + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + $class = false; + + $imageUrl = $this->params['markdown']; + + if(strpos($imageUrl, 'https://www.youtube.com/watch?v=') !== false) + { + $videoID = str_replace('https://www.youtube.com/watch?v=', '', $imageUrl); + $videoID = strpos($videoID, '&') ? substr($videoID, 0, strpos($videoID, '&')) : $videoID; + $class = 'youtube'; + } + if(strpos($imageUrl, 'https://youtu.be/') !== false) + { + $videoID = str_replace('https://youtu.be/', '', $imageUrl); + $videoID = strpos($videoID, '?') ? substr($videoID, 0, strpos($videoID, '?')) : $videoID; + $class = 'youtube'; + } + + if($class == 'youtube') + { + $videoURLmaxres = 'https://i1.ytimg.com/vi/' . $videoID . '/maxresdefault.jpg'; + $videoURL0 = 'https://i1.ytimg.com/vi/' . $videoID . '/0.jpg'; + } + + $ctx = stream_context_create(array( + 'https' => array( + 'timeout' => 1 + ) + ) + ); + + $imageData = @file_get_contents($videoURLmaxres, 0, $ctx); + if($imageData === false) + { + $imageData = @file_get_contents($videoURL0, 0, $ctx); + if($imageData === false) + { + return $response->withJson(array('errors' => 'could not get the video image')); + } + } + + $imageData64 = 'data:image/jpeg;base64,' . base64_encode($imageData); + $desiredSizes = ['live' => ['width' => 560, 'height' => 315]]; + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $tmpImage = $imageProcessor->createImage($imageData64, $desiredSizes); + + if(!$tmpImage) + { + return $response->withJson(array('errors' => 'could not create temporary image')); + } + + $imageUrl = $imageProcessor->publishImage($desiredSizes, $videoID); + if($imageUrl) + { + $this->params['markdown'] = '![' . $class . '-video](' . $imageUrl . ' "click to load video"){#' . $videoID. ' .' . $class . '}'; + + $request = $request->withParsedBody($this->params); + + if($this->params['new']) + { + return $this->addBlock($request, $response, $args); + } + return $this->updateBlock($request, $response, $args); + } + + return $response->withJson(array('errors' => 'could not store the preview image')); + } + + private function getAllowedMtypes() + { + return array( + 'application/zip', + 'application/gzip', + 'application/vnd.rar', + 'application/vnd.visio', + 'application/vnd.ms-excel', + 'application/vnd.ms-powerpoint', + 'application/vnd.ms-word.document.macroEnabled.12', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.apple.keynote', + 'application/vnd.apple.mpegurl', + 'application/vnd.apple.numbers', + 'application/vnd.apple.pages', + 'application/vnd.amazon.mobi8-ebook', + 'application/epub+zip', + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'font/*', + 'audio/mpeg', + 'audio/mp4', + 'audio/ogg', + 'video/mpeg', + 'video/mp4', + 'video/ogg', + ); + } +} \ No newline at end of file diff --git a/system/Controllers/MediaApiController.php b/system/Controllers/MediaApiController.php new file mode 100644 index 0000000..a721197 --- /dev/null +++ b/system/Controllers/MediaApiController.php @@ -0,0 +1,356 @@ +params = $request->getParams(); + $this->uri = $request->getUri(); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + $imagelist = $imageProcessor->scanMediaFlat(); + + return $response->withJson(['images' => $imagelist]); + } + + public function getMediaLibFiles(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + $filelist = $fileProcessor->scanFilesFlat(); + + return $response->withJson(['files' => $filelist]); + } + + public function getImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $this->setStructure($draft = true, $cache = false); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + $imageDetails = $imageProcessor->getImageDetails($this->params['name'], $this->structure); + + if($imageDetails) + { + return $response->withJson(['image' => $imageDetails]); + } + + return $response->withJson(['errors' => 'Image not found or image name not valid.'], 404); + } + + public function getFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $this->setStructure($draft = true, $cache = false); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + $fileDetails = $fileProcessor->getFileDetails($this->params['name'], $this->structure); + + if($fileDetails) + { + return $response->withJson(['file' => $fileDetails]); + } + + return $response->withJson(['errors' => 'file not found or file name invalid'],404); + } + + public function createImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + $imageProcessor = new ProcessImage($this->settings['images']); + + if(!$imageProcessor->checkFolders('images')) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + if($imageProcessor->createImage($this->params['image'], $this->params['name'], $this->settings['images'])) + { + return $response->withJson(['name' => 'media/live/' . $imageProcessor->getFullName(),'errors' => false]); + } + + return $response->withJson(['errors' => 'could not store image to temporary folder']); + } + + public function uploadFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + # make sure only allowed filetypes are uploaded + $finfo = finfo_open( FILEINFO_MIME_TYPE ); + $mtype = finfo_file( $finfo, $this->params['file'] ); + finfo_close( $finfo ); + $allowedMimes = $this->getAllowedMtypes(); + if(!in_array($mtype, $allowedMimes)) + { + return $response->withJson(array('errors' => 'File-type is not allowed')); + } + + $fileProcessor = new ProcessFile(); + + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + $fileinfo = $fileProcessor->storeFile($this->params['file'], $this->params['name']); + if($fileinfo) + { + return $response->withJson(['errors' => false, 'info' => $fileinfo]); + } + + return $response->withJson(['errors' => 'could not store file to temporary folder'],500); + } + + public function publishImage(Request $request, Response $response, $args) + { + $params = $request->getParsedBody(); + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + if($imageProcessor->publishImage()) + { + $request = $request->withParsedBody($params); + + $block = new BlockApiController($this->c); + if($params['new']) + { + return $block->addBlock($request, $response, $args); + } + return $block->updateBlock($request, $response, $args); + } + + return $response->withJson(['errors' => 'could not store image to media folder'],500); + } + + public function publishFile(Request $request, Response $response, $args) + { + $params = $request->getParsedBody(); + + $fileProcessor = new ProcessFile(); + if(!$fileProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + if($fileProcessor->publishFile()) + { + $request = $request->withParsedBody($params); + + $block = new BlockApiController($this->c); + if($params['new']) + { + return $block->addBlock($request, $response, $args); + } + return $block->updateBlock($request, $response, $args); + } + + return $response->withJson(['errors' => 'could not store file to media folder'],500); + } + + public function deleteImage(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + if(!isset($this->params['name'])) + { + return $response->withJson(['errors' => 'image name is missing'],500); + } + + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => 'Please check if your media-folder exists and all folders inside are writable.'], 500); + } + + if($imageProcessor->deleteImage($this->params['name'])) + { + return $response->withJson(['errors' => false]); + } + + return $response->withJson(['errors' => 'Oops, looks like we could not delete all sizes of that image.'], 500); + } + + public function deleteFile(Request $request, Response $response, $args) + { + # get params from call + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + + if(!isset($this->params['name'])) + { + return $response->withJson(['errors' => 'file name is missing'],500); + } + + $fileProcessor = new ProcessFile(); + + if($fileProcessor->deleteFile($this->params['name'])) + { + return $response->withJson(['errors' => false]); + } + + return $response->withJson(['errors' => 'could not delete the file'],500); + } + + public function saveVideoImage(Request $request, Response $response, $args) + { + /* get params from call */ + $this->params = $request->getParams(); + $this->uri = $request->getUri(); + $class = false; + + $imageUrl = $this->params['markdown']; + + if(strpos($imageUrl, 'https://www.youtube.com/watch?v=') !== false) + { + $videoID = str_replace('https://www.youtube.com/watch?v=', '', $imageUrl); + $videoID = strpos($videoID, '&') ? substr($videoID, 0, strpos($videoID, '&')) : $videoID; + $class = 'youtube'; + } + if(strpos($imageUrl, 'https://youtu.be/') !== false) + { + $videoID = str_replace('https://youtu.be/', '', $imageUrl); + $videoID = strpos($videoID, '?') ? substr($videoID, 0, strpos($videoID, '?')) : $videoID; + $class = 'youtube'; + } + + if($class == 'youtube') + { + $videoURLmaxres = 'https://i1.ytimg.com/vi/' . $videoID . '/maxresdefault.jpg'; + $videoURL0 = 'https://i1.ytimg.com/vi/' . $videoID . '/0.jpg'; + } + + $ctx = stream_context_create(array( + 'https' => array( + 'timeout' => 1 + ) + ) + ); + + $imageData = @file_get_contents($videoURLmaxres, 0, $ctx); + if($imageData === false) + { + $imageData = @file_get_contents($videoURL0, 0, $ctx); + if($imageData === false) + { + return $response->withJson(array('errors' => 'could not get the video image')); + } + } + + $imageData64 = 'data:image/jpeg;base64,' . base64_encode($imageData); + $desiredSizes = ['live' => ['width' => 560, 'height' => 315]]; + $imageProcessor = new ProcessImage($this->settings['images']); + if(!$imageProcessor->checkFolders()) + { + return $response->withJson(['errors' => ['message' => 'Please check if your media-folder exists and all folders inside are writable.']], 500); + } + + $tmpImage = $imageProcessor->createImage($imageData64, $desiredSizes); + + if(!$tmpImage) + { + return $response->withJson(array('errors' => 'could not create temporary image')); + } + + $imageUrl = $imageProcessor->publishImage($desiredSizes, $videoID); + if($imageUrl) + { + $this->params['markdown'] = '![' . $class . '-video](' . $imageUrl . ' "click to load video"){#' . $videoID. ' .' . $class . '}'; + + $request = $request->withParsedBody($this->params); + + $block = new BlockApiController($this->c); + if($params['new']) + { + return $block->addBlock($request, $response, $args); + } + return $block->updateBlock($request, $response, $args); + } + + return $response->withJson(array('errors' => 'could not store the preview image')); + } + + private function getAllowedMtypes() + { + return array( + 'application/zip', + 'application/gzip', + 'application/vnd.rar', + 'application/vnd.visio', + 'application/vnd.ms-excel', + 'application/vnd.ms-powerpoint', + 'application/vnd.ms-word.document.macroEnabled.12', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/vnd.apple.keynote', + 'application/vnd.apple.mpegurl', + 'application/vnd.apple.numbers', + 'application/vnd.apple.pages', + 'application/vnd.amazon.mobi8-ebook', + 'application/epub+zip', + 'application/pdf', + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'font/*', + 'audio/mpeg', + 'audio/mp4', + 'audio/ogg', + 'video/mpeg', + 'video/mp4', + 'video/ogg', + ); + } +} \ No newline at end of file diff --git a/system/Controllers/MetaApiController.php b/system/Controllers/MetaApiController.php index 9e80c5e..ab997a1 100644 --- a/system/Controllers/MetaApiController.php +++ b/system/Controllers/MetaApiController.php @@ -200,9 +200,8 @@ class MetaApiController extends ContentController if($tab == 'meta') { - # if manual date has been modified - if(isset($metaInput['manualdate']) && !isset($metaPage['meta']['manualdate']) OR ($metaInput['manualdate'] != $metaPage['meta']['manualdate'])) + if( $this->hasChanged($metaInput, $metaPage['meta'], 'manualdate')) { # update the time $metaInput['time'] = date('H-i-s', time()); @@ -230,7 +229,7 @@ class MetaApiController extends ContentController } # if folder has changed and contains pages instead of posts or posts instead of pages - if($this->item->elementType == "folder" && ($metaPage['meta']['contains'] !== $metaInput['contains'])) + if($this->item->elementType == "folder" && isset($metaInput['contains']) && $this->hasChanged($metaInput, $metaPage['meta'], 'contains')) { $structure = true; @@ -258,9 +257,9 @@ class MetaApiController extends ContentController } elseif( # check if navtitle or hide-value has been changed - ($metaPage['meta']['navtitle'] != $metaInput['navtitle']) + ($this->hasChanged($metaInput, $metaPage['meta'], 'navtitle')) OR - ($metaPage['meta']['hide'] != $metaInput['hide']) + ($this->hasChanged($metaInput, $metaPage['meta'], 'hide')) ) { # add new file data. Also makes sure that the value is set. @@ -297,6 +296,19 @@ class MetaApiController extends ContentController # return with the new metadata return $response->withJson(array('metadata' => $metaInput, 'structure' => $structure, 'item' => $this->item, 'errors' => false)); } + + protected function hasChanged($input, $page, $field) + { + if(isset($input[$field]) && isset($page[$field]) && $input[$field] == $page[$field]) + { + return false; + } + if(!isset($input[$field]) && !isset($input[$field])) + { + return false; + } + return true; + } } # check models -> writeYaml for getPageMeta and getPageMetaDefaults. \ No newline at end of file diff --git a/system/Controllers/PageController.php b/system/Controllers/PageController.php index 929ca2e..b721941 100644 --- a/system/Controllers/PageController.php +++ b/system/Controllers/PageController.php @@ -108,13 +108,16 @@ class PageController extends Controller /* get breadcrumb for page */ $breadcrumb = Folder::getBreadcrumb($structure, $item->keyPathArray); $breadcrumb = $this->c->dispatcher->dispatch('onBreadcrumbLoaded', new OnBreadcrumbLoaded($breadcrumb))->getData(); + + # set pages active for navigation again + Folder::getBreadcrumb($navigation, $item->keyPathArray); /* add the paging to the item */ $item = Folder::getPagingForItem($structure, $item); } # dispatch the item - $item = $this->c->dispatcher->dispatch('onItemLoaded', new OnItemLoaded($item))->getData(); + $item = $this->c->dispatcher->dispatch('onItemLoaded', new OnItemLoaded($item))->getData(); # set the filepath $filePath = $pathToContent . $item->path; @@ -211,6 +214,18 @@ class PageController extends Controller $this->c->assets->addCSS($base_url . '/cache/' . $theme . '-custom.css'); } + $logo = false; + if(isset($settings['logo']) && $settings['logo'] != '') + { + $logo = 'media/files/' . $settings['logo']; + } + + $favicon = false; + if(isset($settings['favicon']) && $settings['favicon'] != '') + { + $favicon = true; + } + return $this->render($response, $route, [ 'home' => $home, 'navigation' => $navigation, @@ -221,7 +236,10 @@ class PageController extends Controller 'settings' => $settings, 'metatabs' => $metatabs, 'base_url' => $base_url, - 'image' => $firstImage ]); + 'image' => $firstImage, + 'logo' => $logo, + 'favicon' => $favicon + ]); } protected function getCachedStructure($cache) diff --git a/system/Controllers/SettingsController.php b/system/Controllers/SettingsController.php index ebe6d3f..bad290f 100644 --- a/system/Controllers/SettingsController.php +++ b/system/Controllers/SettingsController.php @@ -7,6 +7,8 @@ use Typemill\Models\Write; use Typemill\Models\Fields; use Typemill\Models\Validation; use Typemill\Models\User; +use Typemill\Models\ProcessFile; +use Typemill\Models\ProcessImage; class SettingsController extends Controller { @@ -15,7 +17,7 @@ class SettingsController extends Controller *********************/ public function showSettings($request, $response, $args) - { + { $user = new User(); $settings = $this->c->get('settings'); $defaultSettings = \Typemill\Settings::getDefaultSettings(); @@ -46,8 +48,10 @@ class SettingsController extends Controller $settings = \Typemill\Settings::getUserSettings(); $defaultSettings = \Typemill\Settings::getDefaultSettings(); $params = $request->getParams(); + $files = $request->getUploadedFiles(); $newSettings = isset($params['settings']) ? $params['settings'] : false; $validate = new Validation(); + $processFiles = new ProcessFile(); if($newSettings) { @@ -62,6 +66,8 @@ class SettingsController extends Controller 'formats' => $newSettings['formats'], ); + # https://www.slimframework.com/docs/v3/cookbook/uploading-files.html; + $copyright = $this->getCopyright(); $validate->settings($newSettings, $copyright, $defaultSettings['formats'], 'settings'); @@ -77,8 +83,80 @@ class SettingsController extends Controller $this->c->flash->addMessage('error', 'Please correct the errors'); return $response->withRedirect($this->c->router->pathFor('settings.show')); } - - /* store updated settings */ + + if(!$processFiles->checkFolders()) + { + $this->c->flash->addMessage('error', 'Please make sure that your media folder exists and is writable.'); + return $response->withRedirect($this->c->router->pathFor('settings.show')); + } + + # handle single input with single file upload + $logo = $files['settings']['logo']; + if($logo->getError() === UPLOAD_ERR_OK) + { + $allowed = ['jpg', 'jpeg', 'png', 'svg']; + $extension = pathinfo($logo->getClientFilename(), PATHINFO_EXTENSION); + if(!in_array(strtolower($extension), $allowed)) + { + $_SESSION['errors']['settings']['logo'] = array('Only jpg, jpeg, png and svg allowed'); + $this->c->flash->addMessage('error', 'Please correct the errors'); + return $response->withRedirect($this->c->router->pathFor('settings.show')); + } + + $processFiles->deleteFileWithName('logo'); + $newSettings['logo'] = $processFiles->moveUploadedFile($logo, $overwrite = true, $name = 'logo'); + } + elseif(isset($params['settings']['deletelogo']) && $params['settings']['deletelogo'] == 'delete') + { + $processFiles->deleteFileWithName('logo'); + $newSettings['logo'] = ''; + } + else + { + $newSettings['logo'] = isset($settings['logo']) ? $settings['logo'] : ''; + } + + # handle single input with single file upload + $favicon = $files['settings']['favicon']; + if ($favicon->getError() === UPLOAD_ERR_OK) + { + $extension = pathinfo($favicon->getClientFilename(), PATHINFO_EXTENSION); + if(strtolower($extension) != 'png') + { + $_SESSION['errors']['settings']['favicon'] = array('Only .png-files allowed'); + $this->c->flash->addMessage('error', 'Please correct the errors'); + return $response->withRedirect($this->c->router->pathFor('settings.show')); + } + + $processImage = new ProcessImage([ + '16' => ['width' => 16, 'height' => 16], + '32' => ['width' => 32, 'height' => 32], + '72' => ['width' => 72, 'height' => 72], + '114' => ['width' => 114, 'height' => 114], + '144' => ['width' => 144, 'height' => 144], + '180' => ['width' => 180, 'height' => 180], + ]); + $favicons = $processImage->generateSizesFromImageFile('favicon.png', $favicon->file); + + foreach($favicons as $key => $favicon) + { + imagepng( $favicon, $processFiles->fileFolder . 'favicon-' . $key . '.png' ); + # $processFiles->moveUploadedFile($favicon, $overwrite = true, $name = 'favicon-' . $key); + } + + $newSettings['favicon'] = 'favicon'; + } + elseif(isset($params['settings']['deletefav']) && $params['settings']['deletefav'] == 'delete') + { + $processFiles->deleteFileWithName('favicon'); + $newSettings['favicon'] = ''; + } + else + { + $newSettings['favicon'] = isset($settings['favicon']) ? $settings['favicon'] : ''; + } + + # store updated settings \Typemill\Settings::updateSettings(array_merge($settings, $newSettings)); $this->c->flash->addMessage('info', 'Settings are stored'); diff --git a/system/Controllers/SetupController.php b/system/Controllers/SetupController.php index 5a1acca..e149ed1 100644 --- a/system/Controllers/SetupController.php +++ b/system/Controllers/SetupController.php @@ -9,6 +9,13 @@ use Typemill\Models\Write; class SetupController extends Controller { + + # redirect if visit /setup route + public function redirect($request, $response) + { + return $response->withRedirect($this->c->router->pathFor('setup.show')); + } + public function show($request, $response, $args) { /* make some checks befor you install */ diff --git a/system/Extensions/TwigPagelistExtension.php b/system/Extensions/TwigPagelistExtension.php new file mode 100644 index 0000000..e85e1b6 --- /dev/null +++ b/system/Extensions/TwigPagelistExtension.php @@ -0,0 +1,22 @@ + $item) + { + # set item active, needed to move item in navigation + if($item->urlRelWoF === $url) + { + $item->active = true; + $result = $item; + } + elseif($item->elementType === "folder") + { + $result = self::getItemForUrlFrontend($item->folderContent, $url, $result); + } + } + + return $result; + } + public static function getPagingForItem($content, $item) { $keyPos = count($item->keyPathArray)-1; @@ -475,6 +493,7 @@ class Folder while($i < count($searchArray)) { + if(!isset($content[$searchArray[$i]])){ return false; } $item = $content[$searchArray[$i]]; if($i == count($searchArray)-1) diff --git a/system/Models/Helpers.php b/system/Models/Helpers.php index ed35cde..1a189b8 100644 --- a/system/Models/Helpers.php +++ b/system/Models/Helpers.php @@ -24,4 +24,39 @@ class Helpers{ $table .= ''; echo $table; } + + public static function array_sort($array, $on, $order=SORT_ASC) + { + $new_array = array(); + $sortable_array = array(); + + if (count($array) > 0) { + foreach ($array as $k => $v) { + if (is_array($v)) { + foreach ($v as $k2 => $v2) { + if ($k2 == $on) { + $sortable_array[$k] = $v2; + } + } + } else { + $sortable_array[$k] = $v; + } + } + + switch ($order) { + case SORT_ASC: + asort($sortable_array); + break; + case SORT_DESC: + arsort($sortable_array); + break; + } + + foreach ($sortable_array as $k => $v) { + $new_array[] = $array[$k]; + } + } + + return $new_array; + } } \ No newline at end of file diff --git a/system/Models/ProcessAssets.php b/system/Models/ProcessAssets.php new file mode 100644 index 0000000..aaf66c4 --- /dev/null +++ b/system/Models/ProcessAssets.php @@ -0,0 +1,231 @@ +baseFolder = getcwd() . DIRECTORY_SEPARATOR; + + $this->mediaFolder = $this->baseFolder . 'media' . DIRECTORY_SEPARATOR; + + $this->tmpFolder = $this->mediaFolder . 'tmp' . DIRECTORY_SEPARATOR; + + $this->originalFolder = $this->mediaFolder . 'original' . DIRECTORY_SEPARATOR; + + $this->liveFolder = $this->mediaFolder . 'live' . DIRECTORY_SEPARATOR; + + $this->thumbFolder = $this->mediaFolder . 'thumbs' . DIRECTORY_SEPARATOR; + + $this->fileFolder = $this->mediaFolder . 'files' . DIRECTORY_SEPARATOR; + + $this->desiredSizes = $desiredSizes; + } + + public function checkFolders($forassets = null) + { + + $folders = [$this->mediaFolder, $this->tmpFolder, $this->fileFolder]; + + if($forassets == 'images') + { + $folders = [$this->mediaFolder, $this->tmpFolder, $this->originalFolder, $this->liveFolder, $this->thumbFolder]; + } + + foreach($folders as $folder) + { + if(!file_exists($folder)) + { + if(!mkdir($folder, 0774, true)) + { + return false; + } + if($folder == $this->thumbFolder) + { + # cleanup old systems + $this->cleanupLiveFolder(); + + # generate thumbnails from live folder + $this->generateThumbs(); + } + } + elseif(!is_writeable($folder)) + { + return false; + } + } + return true; + } + + public function setFileName($originalname, $type, $overwrite = null) + { + $pathinfo = pathinfo($originalname); + + $this->extension = strtolower($pathinfo['extension']); + $this->filename = URLify::filter(iconv(mb_detect_encoding($pathinfo['filename'], mb_detect_order(), true), "UTF-8", $pathinfo['filename'])); + + $filename = $this->filename; + + # check if file name is + if(!$overwrite) + { + $suffix = 1; + + $destination = $this->liveFolder; + if($type == 'file') + { + $destination = $this->fileFolder; + } + + while(file_exists($destination . $filename . '.' . $this->extension)) + { + $filename = $this->filename . '-' . $suffix; + $suffix++; + } + } + + $this->filename = $filename; + + return true; + } + + public function getName() + { + return $this->filename; + } + + public function getExtension() + { + return $this->extension; + } + + public function getFullName() + { + return $this->filename . '.' . $this->extension; + } + + public function clearTempFolder() + { + $files = scandir($this->tmpFolder); + $result = true; + + foreach($files as $file) + { + if (!in_array($file, array(".",".."))) + { + $filelink = $this->tmpFolder . $file; + if(!unlink($filelink)) + { + $success = false; + } + } + } + + return $result; + } + + public function cleanupLiveFolder() + { + # delete all old thumbs mlibrary in live folder + foreach(glob($this->liveFolder . '*mlibrary*') as $filename) + { + unlink($filename); + } + + return true; + } + + public function findPagesWithUrl($structure, $url, $result) + { + foreach ($structure as $key => $item) + { + if($item->elementType == 'folder') + { + $result = $this->findPagesWithUrl($item->folderContent, $url, $result); + } + else + { + $live = getcwd() . DIRECTORY_SEPARATOR . 'content' . $item->pathWithoutType . '.md'; + $draft = getcwd() . DIRECTORY_SEPARATOR . 'content' . $item->pathWithoutType . '.txt'; + + # check live first + if(file_exists($live)) + { + $content = file_get_contents($live); + + if (stripos($content, $url) !== false) + { + $result[] = $item->urlRelWoF; + } + # if not in live, check in draft + elseif(file_exists($draft)) + { + $content = file_get_contents($draft); + + if (stripos($content, $url) !== false) + { + $result[] = $item->urlRelWoF; + } + } + } + } + } + return $result; + } + + public function formatSizeUnits($bytes) + { + if ($bytes >= 1073741824) + { + $bytes = number_format($bytes / 1073741824, 2) . ' GB'; + } + elseif ($bytes >= 1048576) + { + $bytes = number_format($bytes / 1048576, 2) . ' MB'; + } + elseif ($bytes >= 1024) + { + $bytes = number_format($bytes / 1024, 2) . ' KB'; + } + elseif ($bytes > 1) + { + $bytes = $bytes . ' bytes'; + } + elseif ($bytes == 1) + { + $bytes = $bytes . ' byte'; + } + else + { + $bytes = '0 bytes'; + } + + return $bytes; + } +} \ No newline at end of file diff --git a/system/Models/ProcessFile.php b/system/Models/ProcessFile.php new file mode 100644 index 0000000..6912956 --- /dev/null +++ b/system/Models/ProcessFile.php @@ -0,0 +1,165 @@ +setFileName($uploadedFile->getClientFilename(), 'file'); + + if($name) + { + $this->setFileName($name . '.' . $this->extension, 'file', $overwrite); + } + + $uploadedFile->moveTo($this->fileFolder . $this->getFullName()); + + return $this->getFullName(); + } + + public function storeFile($file, $name) + { + $this->setFileName($name, 'file'); + + $this->clearTempFolder(); + + $file = $this->decodeFile($file); + + $path = $this->tmpFolder . $this->getFullName(); + + if(file_put_contents($path, $file)) + { + $size = filesize($path); + $size = $this->formatSizeUnits($size); + + $title = str_replace('-', ' ', $this->filename); + $title = $title . ' (' . strtoupper($this->extension) . ', ' . $size .')'; + + return ['title' => $title, 'name' => $this->filename, 'extension' => $this->extension, 'size' => $size, 'url' => 'media/files/' . $this->getFullName()]; + } + + return false; + } + + public function publishFile() + { + $files = scandir($this->tmpFolder); + $success = true; + + foreach($files as $file) + { + if (!in_array($file, array(".",".."))) + { + $success = rename($this->tmpFolder . $file, $this->fileFolder . $file); + } + } + + return $success; + } + + public function decodeFile(string $file) + { + $fileParts = explode(";base64,", $file); + $fileType = explode("/", $fileParts[0]); + $fileData = base64_decode($fileParts[1]); + + if ($fileData !== false) + { + return array("file" => $fileData, "type" => $fileType[1]); + } + + return false; + } + + + public function deleteFile($name) + { + # validate name + $name = basename($name); + + if(file_exists($this->fileFolder . $name) && unlink($this->fileFolder . $name)) + { + return true; + } + + return false; + } + + + public function deleteFileWithName($name) + { + # e.g. delete $name = 'logo'; + + $name = basename($name); + + if($name != '' && !in_array($name, array(".",".."))) + { + foreach(glob($this->fileFolder . $name . '.*') as $file) + { + unlink($file); + } + } + } + + + /* + * scans content of a folder (without recursion) + * vars: folder path as string + * returns: one-dimensional array with names of folders and files + */ + public function scanFilesFlat() + { + $files = scandir($this->fileFolder); + $filelist = array(); + + foreach ($files as $key => $name) + { + if (!in_array($name, array(".","..")) && file_exists($this->fileFolder . $name)) + { + $filelist[] = [ + 'name' => $name, + 'timestamp' => filemtime($this->fileFolder . $name), + 'info' => pathinfo($this->fileFolder . $name), + 'url' => 'media/files/' . $name, + ]; + } + } + + $filelist = Helpers::array_sort($filelist, 'timestamp', SORT_DESC); + + return $filelist; + } + + + public function getFileDetails($name, $structure) + { + $name = basename($name); + + if (!in_array($name, array(".","..")) && file_exists($this->fileFolder . $name)) + { + $filedetails = [ + 'name' => $name, + 'timestamp' => filemtime($this->fileFolder . $name), + 'bytes' => filesize($this->fileFolder . $name), + 'info' => pathinfo($this->fileFolder . $name), + 'url' => 'media/files/' . $name, + 'pages' => $this->findPagesWithUrl($structure, $name, $result = []) + ]; + + return $filedetails; + } + + return false; + } +} \ No newline at end of file diff --git a/system/Models/ProcessImage.php b/system/Models/ProcessImage.php index 2922633..82a21bd 100644 --- a/system/Models/ProcessImage.php +++ b/system/Models/ProcessImage.php @@ -1,9 +1,11 @@ clearTempFolder(); - + + # set the name of the image + $this->setFileName($name, 'image'); + # decode the image from base64-string $imageDecoded = $this->decodeImage($image); $imageData = $imageDecoded["image"]; @@ -28,25 +33,24 @@ class ProcessImage # resize the images $resizedImages = $this->imageResize($image, $imageSize, $desiredSizes, $imageType); - - $basePath = getcwd() . DIRECTORY_SEPARATOR . 'media'; - $tmpFolder = $basePath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; - $this->saveOriginal($tmpFolder, $imageData, 'original', $imageType); - - if($imageType == "gif" && $this->detectAnimatedGif($imageData)) - { - $this->saveOriginal($tmpFolder, $imageData, 'live', $imageType); + # store the original name as txt-file + $tmpname = fopen($this->tmpFolder . $this->getName() . '.' . $imageType . ".txt", "w"); + + $this->saveOriginal($this->tmpFolder, $imageData, $name = 'original', $imageType); - return true; - } - # temporary store resized images foreach($resizedImages as $key => $resizedImage) { - $this->saveImage($tmpFolder, $resizedImage, $key, $imageType); + $this->saveImage($this->tmpFolder, $resizedImage, $key, $imageType); } - + + # if the image is an animated gif, then overwrite the resized version for live use with the original version + if($imageType == "gif" && $this->detectAnimatedGif($imageData)) + { + $this->saveOriginal($this->tmpFolder, $imageData, $name = 'live', $imageType); + } + return true; } @@ -60,42 +64,54 @@ class ProcessImage return false; } - public function publishImage(array $desiredSizes, $name = false) + public function publishImage() { - /* get images from tmp folder */ - $basePath = getcwd() . DIRECTORY_SEPARATOR . 'media'; - $tmpFolder = $basePath . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; - $originalFolder = $basePath . DIRECTORY_SEPARATOR . 'original' . DIRECTORY_SEPARATOR; - $liveFolder = $basePath . DIRECTORY_SEPARATOR . 'live' . DIRECTORY_SEPARATOR; + # name is stored in temporary folder as name of the .txt-file + foreach(glob($this->tmpFolder . '*.txt') as $imagename) + { + $tmpname = str_replace('.txt', '', basename($imagename)); - if(!file_exists($originalFolder)){ mkdir($originalFolder, 0774, true); } - if(!file_exists($liveFolder)){ mkdir($liveFolder, 0774, true); } + # set extension and sanitize name + $this->setFileName($tmpname, 'image'); - $name = $name ? $name : uniqid(); - - $files = scandir($tmpFolder); + unlink($imagename); + } + + $name = uniqid(); + + if($this->filename && $this->extension) + { + $name = $this->filename; + } + + $files = scandir($this->tmpFolder); $success = true; foreach($files as $file) { if (!in_array($file, array(".",".."))) - { + { $tmpfilename = explode(".", $file); if($tmpfilename[0] == 'original') { - $success = rename($tmpFolder . $file, $originalFolder . $name . '-' . $file); + $success = rename($this->tmpFolder . $file, $this->originalFolder . $name . '.' . $tmpfilename[1]); } - else + if($tmpfilename[0] == 'live') { - $success = rename($tmpFolder . $file, $liveFolder . $name . '-' . $file); + $success = rename($this->tmpFolder . $file, $this->liveFolder . $name . '.' . $tmpfilename[1]); + } + if($tmpfilename[0] == 'thumbs') + { + $success = rename($this->tmpFolder . $file, $this->thumbFolder . $name . '.' . $tmpfilename[1]); } } } if($success) { - return 'media/live/' . $name . '-live.' . $tmpfilename[1]; + return true; + return 'media/live/' . $name . '.' . $tmpfilename[1]; } return false; @@ -126,6 +142,7 @@ class ProcessImage { foreach($desiredSizes as $key => $desiredSize) { + # if desired size is bigger than the actual image, then drop the desired sizes and use the actual image size instead if($desiredSize['width'] > $imageSize['width']) { $desiredSizes[$key] = $imageSize; @@ -141,129 +158,61 @@ class ProcessImage return $desiredSizes; } - public function imageResize($imageData, array $imageSize, array $desiredSizes, $imageType) + public function imageResize($imageData, array $source, array $desiredSizes, $imageType) { + $copiedImages = array(); - $source_aspect_ratio = $imageSize['width'] / $imageSize['height']; - - foreach($desiredSizes as $key => $desiredSize) + + foreach($desiredSizes as $key => $desired) { - $desired_aspect_ratio = $desiredSize['width'] / $desiredSize['height']; + // resize + $ratio = max($desired['width']/$source['width'], $desired['height']/$source['height']); + $h = $desired['height'] / $ratio; + $x = ($source['width'] - $desired['width'] / $ratio) / 2; + $y = ($source['height'] - $desired['height'] / $ratio) / 2; + $w = $desired['width'] / $ratio; - if ( $source_aspect_ratio > $desired_aspect_ratio ) - { - # when source image is wider - $temp_height = $desiredSize['height']; - $temp_width = ( int ) ($desiredSize['height'] * $source_aspect_ratio); - $temp_width = round($temp_width, 0); - } - else - { - # when source image is similar or taller - $temp_width = $desiredSize['width']; - $temp_height = ( int ) ($desiredSize['width'] / $source_aspect_ratio); - $temp_height = round($temp_height, 0); - } + $new = imagecreatetruecolor($desired['width'], $desired['height']); - # Create a temporary GD image with desired size - $temp_gdim = imagecreatetruecolor( $temp_width, $temp_height ); + // preserve transparency + if($imageType == "gif" or $imageType == "png") + { + imagecolortransparent($new, imagecolorallocatealpha($new, 0, 0, 0, 127)); + imagealphablending($new, false); + imagesavealpha($new, true); + } - if ($imageType == "gif") - { - $transparent_index = imagecolortransparent($imageData); - imagepalettecopy($imageData, $temp_gdim); - imagefill($temp_gdim, 0, 0, $transparent_index); - imagecolortransparent($temp_gdim, $transparent_index); - imagetruecolortopalette($temp_gdim, true, 256); - } - elseif($imageType == "png") - { - imagealphablending($temp_gdim, false); - imagesavealpha($temp_gdim, true); - $transparent = imagecolorallocatealpha($temp_gdim, 255, 255, 255, 127); - imagefilledrectangle($temp_gdim, 0, 0, $temp_width, $temp_height, $transparent); - } - - # resize image - imagecopyresampled( - $temp_gdim, - $imageData, - 0, 0, - 0, 0, - $temp_width, $temp_height, - $imageSize['width'], $imageSize['height'] - ); + imagecopyresampled($new, $imageData, 0, 0, $x, $y, $desired['width'], $desired['height'], $w, $h); - $copiedImages[$key] = $temp_gdim; - - /* - - # Copy cropped region from temporary image into the desired GD image - $x0 = ( $temp_width - $desiredSize['width'] ) / 2; - $y0 = ( $temp_height - $desiredSize['height'] ) / 2; - - $desired_gdim = imagecreatetruecolor( $desiredSize['width'], $desiredSize['height'] ); - - if ($imageType == "gif") - { - imagepalettecopy($temp_gdim, $desired_gdim); - imagefill($desired_gdim, 0, 0, $transparent_index); - imagecolortransparent($desired_gdim, $transparent_index); - imagetruecolortopalette($desired_gdim, true, 256); - } - elseif($imageType == "png") - { - imagealphablending($desired_gdim, false); - imagesavealpha($desired_gdim,true); - $transparent = imagecolorallocatealpha($desired_gdim, 255, 255, 255, 127); - imagefilledrectangle($desired_gdim, 0, 0, $desired_size['with'], $desired_size['height'], $transparent); - } - - imagecopyresampled( - $desired_gdim, - $temp_gdim, - 0, 0, - 0, 0, - $x0, $y0, - $desiredSize['width'], $desiredSize['height'] - ); - $copiedImages[$key] = $desired_gdim; - - */ + $copiedImages[$key] = $new; } + return $copiedImages; } + # save original in temporary folder public function saveOriginal($folder, $image, $name, $type) - { - if(!file_exists($folder)) - { - mkdir($folder, 0774, true); - } - + { $path = $folder . $name . '.' . $type; file_put_contents($path, $image); } + + # save resized images in temporary folder public function saveImage($folder, $image, $name, $type) - { - if(!file_exists($folder)) - { - mkdir($folder, 0774, true); - } - + { if($type == "png") { - $result = imagepng( $image, $folder . '/' . $name . '.png' ); + $result = imagepng( $image, $folder . $name . '.png' ); } elseif($type == "gif") { - $result = imagegif( $image, $folder . '/' . $name . '.gif' ); + $result = imagegif( $image, $folder . $name . '.gif' ); } else { - $result = imagejpeg( $image, $folder . '/' . $name . '.jpeg' ); + $result = imagejpeg( $image, $folder . $name . '.jpeg' ); $type = 'jpeg'; } @@ -277,60 +226,156 @@ class ProcessImage return false; } - public function clearTempFolder() - { - $folder = getcwd() . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR . 'tmp' . DIRECTORY_SEPARATOR; - - if(!file_exists($folder)) - { - mkdir($folder, 0774, true); - return true; - } - - $files = scandir($folder); - $result = true; - - foreach($files as $file) - { - if (!in_array($file, array(".",".."))) - { - $filelink = $folder . $file; - if(!unlink($filelink)) - { - $success = false; - } - } - } - - return $result; - } - public function deleteImage($name) { - $baseFolder = getcwd() . DIRECTORY_SEPARATOR . 'media' . DIRECTORY_SEPARATOR; - $original = $baseFolder . 'original' . DIRECTORY_SEPARATOR . $name . '*'; - $live = $baseFolder . 'live' . DIRECTORY_SEPARATOR . $name . '*'; - $success = true; - - foreach(glob($original) as $image) + + # validate name + $name = basename($name); + + $result = true; + + if(!file_exists($this->originalFolder . $name) OR !unlink($this->originalFolder . $name)) + { + $result = false; + } + + if(!file_exists($this->liveFolder . $name) OR !unlink($this->liveFolder . $name)) + { + $result = false; + } + + if(!file_exists($this->thumbFolder . $name) OR !unlink($this->thumbFolder . $name)) + { + $result = false; + } + + # you should not use glob but exact name with ending + /* + foreach(glob($this->originalFolder . $name) as $image) { if(!unlink($image)) { $success = false; } } + */ - foreach(glob($live) as $image) - { - if(!unlink($image)) - { - $success = false; - } - } - - return $success; + # array_map('unlink', glob("some/dir/*.txt")); + + return $result; + } + + /* + * scans content of a folder (without recursion) + * vars: folder path as string + * returns: one-dimensional array with names of folders and files + */ + public function scanMediaFlat() + { + $thumbs = array_diff(scandir($this->thumbFolder), array('..', '.')); + $imagelist = array(); + + foreach ($thumbs as $key => $name) + { + if (file_exists($this->liveFolder . $name)) + { + $imagelist[] = [ + 'name' => $name, + 'timestamp' => filemtime($this->liveFolder . $name), + 'src_thumb' => 'media/thumbs/' . $name, + 'src_live' => 'media/live/' . $name, + ]; + } + } + + $imagelist = Helpers::array_sort($imagelist, 'timestamp', SORT_DESC); + + return $imagelist; } -} -?> \ No newline at end of file + public function getImageDetails($name, $structure) + { + $name = basename($name); + + if (!in_array($name, array(".","..")) && file_exists($this->liveFolder . $name)) + { + $imageinfo = getimagesize($this->liveFolder . $name); + + $imagedetails = [ + 'name' => $name, + 'timestamp' => filemtime($this->liveFolder . $name), + 'bytes' => filesize($this->liveFolder . $name), + 'width' => $imageinfo[0], + 'height' => $imageinfo[1], + 'type' => $imageinfo['mime'], + 'src_thumb' => 'media/thumbs/' . $name, + 'src_live' => 'media/live/' . $name, + 'pages' => $this->findPagesWithUrl($structure, $name, $result = []) + ]; + + return $imagedetails; + } + + return false; + } + + public function generateThumbs() + { + # generate images from live folder to 'tmthumbs' + $liveImages = scandir($this->liveFolder); + + foreach ($liveImages as $key => $name) + { + if (!in_array($name, array(".",".."))) + { + $this->generateThumbFromImageFile($name); + } + } + } + + public function generateThumbFromImageFile($filename) + { + $this->setFileName($filename, 'image', $overwrite = true); + + if($this->extension == 'jpeg') $this->extension = 'jpg'; + + switch($this->extension) + { + case 'gif': $image = imagecreatefromgif($this->liveFolder . $filename); break; + case 'jpg': $image = imagecreatefromjpeg($this->liveFolder . $filename); break; + case 'png': $image = imagecreatefrompng($this->liveFolder . $filename); break; + default: return 'image type not supported'; + } + + $originalSize = $this->getImageSize($image); + + $thumbSize = $this->desiredSizes['thumbs']; + + $thumb = $this->imageResize($image, $originalSize, ['thumbs' => $thumbSize ], $this->extension); + + $this->saveImage($this->thumbFolder, $thumb['thumbs'], $this->filename, $this->extension); + } + + public function generateSizesFromImageFile($filename, $image) + { + $this->setFileName($filename, 'image'); + + if($this->extension == 'jpeg') $this->extension = 'jpg'; + + switch($this->extension) + { + case 'gif': $image = imagecreatefromgif($image); break; + case 'jpg': $image = imagecreatefromjpeg($image); break; + case 'png': $image = imagecreatefrompng($image); break; + default: return 'image type not supported'; + } + + $originalSize = $this->getImageSize($image); + + $resizedImages = $this->imageResize($image, $originalSize, $this->desiredSizes, $this->extension); + + return $resizedImages; + } + +} \ No newline at end of file diff --git a/system/Routes/Api.php b/system/Routes/Api.php index a81adbd..f1095a6 100644 --- a/system/Routes/Api.php +++ b/system/Routes/Api.php @@ -3,34 +3,45 @@ use Typemill\Controllers\SettingsController; use Typemill\Controllers\ContentController; use Typemill\Controllers\ContentApiController; +use Typemill\Controllers\ArticleApiController; +use Typemill\Controllers\BlockApiController; +use Typemill\Controllers\MediaApiController; use Typemill\Controllers\MetaApiController; use Typemill\Middleware\RestrictApiAccess; $app->get('/api/v1/themes', SettingsController::class . ':getThemeSettings')->setName('api.themes')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/article/markdown', ContentApiController::class . ':getArticleMarkdown')->setName('api.article.markdown')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/article/html', ContentApiController::class . ':getArticleHtml')->setName('api.article.html')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/article/publish', ContentApiController::class . ':publishArticle')->setName('api.article.publish')->add(new RestrictApiAccess($container['router'])); -$app->delete('/api/v1/article/unpublish', ContentApiController::class . ':unpublishArticle')->setName('api.article.unpublish')->add(new RestrictApiAccess($container['router'])); -$app->delete('/api/v1/article/discard', ContentApiController::class . ':discardArticleChanges')->setName('api.article.discard')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/post', ContentApiController::class . ':createPost')->setName('api.post.create')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/article', ContentApiController::class . ':createArticle')->setName('api.article.create')->add(new RestrictApiAccess($container['router'])); -$app->put('/api/v1/article', ContentApiController::class . ':updateArticle')->setName('api.article.update')->add(new RestrictApiAccess($container['router'])); -$app->delete('/api/v1/article', ContentApiController::class . ':deleteArticle')->setName('api.article.delete')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/article/sort', ContentApiController::class . ':sortArticle')->setName('api.article.sort')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/article/markdown', ArticleApiController::class . ':getArticleMarkdown')->setName('api.article.markdown')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/article/html', ArticleApiController::class . ':getArticleHtml')->setName('api.article.html')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/article/publish', ArticleApiController::class . ':publishArticle')->setName('api.article.publish')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/article/unpublish', ArticleApiController::class . ':unpublishArticle')->setName('api.article.unpublish')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/article/discard', ArticleApiController::class . ':discardArticleChanges')->setName('api.article.discard')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/article/sort', ArticleApiController::class . ':sortArticle')->setName('api.article.sort')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/article', ArticleApiController::class . ':createArticle')->setName('api.article.create')->add(new RestrictApiAccess($container['router'])); +$app->put('/api/v1/article', ArticleApiController::class . ':updateArticle')->setName('api.article.update')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/article', ArticleApiController::class . ':deleteArticle')->setName('api.article.delete')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/baseitem', ArticleApiController::class . ':createBaseItem')->setName('api.baseitem.create')->add(new RestrictApiAccess($container['router'])); +$app->get('/api/v1/navigation', ArticleApiController::class . ':getNavigation')->setName('api.navigation.get')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/post', ArticleApiController::class . ':createPost')->setName('api.post.create')->add(new RestrictApiAccess($container['router'])); + +$app->get('/api/v1/metadefinitions', MetaApiController::class . ':getMetaDefinitions')->setName('api.metadefinitions.get')->add(new RestrictApiAccess($container['router'])); $app->get('/api/v1/article/metaobject', MetaApiController::class . ':getArticleMetaobject')->setName('api.articlemetaobject.get')->add(new RestrictApiAccess($container['router'])); $app->get('/api/v1/article/metadata', MetaApiController::class . ':getArticleMeta')->setName('api.articlemeta.get')->add(new RestrictApiAccess($container['router'])); $app->post('/api/v1/article/metadata', MetaApiController::class . ':updateArticleMeta')->setName('api.articlemeta.update')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/baseitem', ContentApiController::class . ':createBaseItem')->setName('api.baseitem.create')->add(new RestrictApiAccess($container['router'])); -$app->get('/api/v1/navigation', ContentApiController::class . ':getNavigation')->setName('api.navigation.get')->add(new RestrictApiAccess($container['router'])); -$app->get('/api/v1/metadefinitions', MetaApiController::class . ':getMetaDefinitions')->setName('api.metadefinitions.get')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/block', ContentApiController::class . ':addBlock')->setName('api.block.add')->add(new RestrictApiAccess($container['router'])); -$app->put('/api/v1/block', ContentApiController::class . ':updateBlock')->setName('api.block.update')->add(new RestrictApiAccess($container['router'])); -$app->delete('/api/v1/block', ContentApiController::class . ':deleteBlock')->setName('api.block.delete')->add(new RestrictApiAccess($container['router'])); -$app->put('/api/v1/moveblock', ContentApiController::class . ':moveBlock')->setName('api.block.move')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/block', BlockApiController::class . ':addBlock')->setName('api.block.add')->add(new RestrictApiAccess($container['router'])); +$app->put('/api/v1/block', BlockApiController::class . ':updateBlock')->setName('api.block.update')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/block', BlockApiController::class . ':deleteBlock')->setName('api.block.delete')->add(new RestrictApiAccess($container['router'])); +$app->put('/api/v1/moveblock', BlockApiController::class . ':moveBlock')->setName('api.block.move')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/video', BlockApiController::class . ':saveVideoImage')->setName('api.video.save')->add(new RestrictApiAccess($container['router'])); -$app->post('/api/v1/image', ContentApiController::class . ':createImage')->setName('api.image.create')->add(new RestrictApiAccess($container['router'])); -$app->put('/api/v1/image', ContentApiController::class . ':publishImage')->setName('api.image.publish')->add(new RestrictApiAccess($container['router'])); - -$app->post('/api/v1/video', ContentApiController::class . ':saveVideoImage')->setName('api.video.save')->add(new RestrictApiAccess($container['router'])); \ No newline at end of file +$app->get('/api/v1/medialib/images', MediaApiController::class . ':getMediaLibImages')->setName('api.medialibimg.get')->add(new RestrictApiAccess($container['router'])); +$app->get('/api/v1/medialib/files', MediaApiController::class . ':getMediaLibFiles')->setName('api.medialibfiles.get')->add(new RestrictApiAccess($container['router'])); +$app->get('/api/v1/image', MediaApiController::class . ':getImage')->setName('api.image.get')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/image', MediaApiController::class . ':createImage')->setName('api.image.create')->add(new RestrictApiAccess($container['router'])); +$app->put('/api/v1/image', MediaApiController::class . ':publishImage')->setName('api.image.publish')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/image', MediaApiController::class . ':deleteImage')->setName('api.image.delete')->add(new RestrictApiAccess($container['router'])); +$app->get('/api/v1/file', MediaApiController::class . ':getFile')->setName('api.file.get')->add(new RestrictApiAccess($container['router'])); +$app->post('/api/v1/file', MediaApiController::class . ':uploadFile')->setName('api.file.upload')->add(new RestrictApiAccess($container['router'])); +$app->put('/api/v1/file', MediaApiController::class . ':publishFile')->setName('api.file.publish')->add(new RestrictApiAccess($container['router'])); +$app->delete('/api/v1/file', MediaApiController::class . ':deleteFile')->setName('api.file.delete')->add(new RestrictApiAccess($container['router'])); \ No newline at end of file diff --git a/system/Routes/Web.php b/system/Routes/Web.php index 133e5dd..e3d227b 100644 --- a/system/Routes/Web.php +++ b/system/Routes/Web.php @@ -69,4 +69,11 @@ foreach($routes as $pluginRoute) } } -$app->get('/[{params:.*}]', PageController::class . ':index')->setName('home'); \ No newline at end of file +if($settings['settings']['setup']) +{ + $app->get('/[{params:.*}]', SetupController::class . ':redirect'); +} +else +{ + $app->get('/[{params:.*}]', PageController::class . ':index')->setName('home'); +} \ No newline at end of file diff --git a/system/Settings.php b/system/Settings.php index b2931fb..6e469ae 100644 --- a/system/Settings.php +++ b/system/Settings.php @@ -16,6 +16,25 @@ class Settings $settings = array_merge($defaultSettings, $userSettings); } + # if there is no theme set + if(!isset($settings['theme'])) + { + # scan theme folder and get the first theme + $themefolder = $settings['rootPath'] . $settings['themeFolder'] . DIRECTORY_SEPARATOR; + $themes = array_diff(scandir($themefolder), array('..', '.')); + $firsttheme = reset($themes); + + # if there is a theme with valid theme settings-file + if($firsttheme && self::getObjectSettings('themes', $firsttheme)) + { + $settings['theme'] = $firsttheme; + } + else + { + die('There is no theme in the theme-folder. Please add a theme from https://themes.typemill.net'); + } + } + # i18n # load the strings of the set language $language = $settings['language']; @@ -30,7 +49,7 @@ class Settings { $themeSettings = self::getObjectSettings('themes', $settings['theme']); $settings['themes'][$settings['theme']] = isset($themeSettings['settings']) ? $themeSettings['settings'] : false; - } + } return array('settings' => $settings); } @@ -48,7 +67,6 @@ class Settings 'language' => 'en', 'startpage' => true, 'rootPath' => $rootPath, - 'theme' => 'typemill', 'themeFolder' => 'themes', 'themeBasePath' => $rootPath, 'themePath' => '', @@ -56,14 +74,14 @@ class Settings 'userPath' => $rootPath . 'settings' . DIRECTORY_SEPARATOR . 'users', 'authorPath' => __DIR__ . DIRECTORY_SEPARATOR . 'author' . DIRECTORY_SEPARATOR, 'editor' => 'visual', - 'formats' => ['markdown', 'headline', 'ulist', 'olist', 'table', 'quote', 'image', 'video', 'toc', 'hr', 'definition', 'code'], + 'formats' => ['markdown', 'headline', 'ulist', 'olist', 'table', 'quote', 'image', 'video', 'file', 'toc', 'hr', 'definition', 'code'], 'contentFolder' => 'content', 'cache' => true, 'cachePath' => $rootPath . 'cache', 'version' => '1.3.3', 'setup' => true, 'welcome' => true, - 'images' => ['live' => ['width' => 820], 'mlibrary' => ['width' => 50, 'height' => 50]], + 'images' => ['live' => ['width' => 820], 'thumbs' => ['width' => 250, 'height' => 150]], ]; } @@ -137,23 +155,25 @@ class Settings if($userSettings) { - # whitelist settings that can be stored in usersettings (values are not relevant here, only keys) + # whitelist settings that can be stored in usersettings (values are not relevant here, only keys) $allowedUserSettings = ['displayErrorDetails' => true, - 'title' => false, - 'copyright' => false, - 'language' => false, - 'startpage' => false, - 'author' => false, - 'year' => false, - 'theme' => false, - 'editor' => false, - 'formats' => false, - 'setup' => false, - 'welcome' => false, - 'images' => false, - 'plugins' => false, - 'themes' => false, - 'latestVersion' => false + 'title' => true, + 'copyright' => true, + 'language' => true, + 'startpage' => true, + 'author' => true, + 'year' => true, + 'theme' => true, + 'editor' => true, + 'formats' => true, + 'setup' => true, + 'welcome' => true, + 'images' => true, + 'plugins' => true, + 'themes' => true, + 'latestVersion' => true, + 'logo' => true, + 'favicon' => true, ]; # cleanup the existing usersettings diff --git a/system/author/css/style.css b/system/author/css/style.css index 29d5df1..d951ff6 100644 --- a/system/author/css/style.css +++ b/system/author/css/style.css @@ -2,7 +2,7 @@ * TRANSITION * **********************/ -a, a:link, a:visited, a:focus, a:hover, a:active, button, .button, .tab-button, input, .control-group, .sidebar-menu, .sidebar-menu--content, .menu-action, .button-arrow{ +a, a:link, a:visited, a:focus, a:hover, a:active, .link, button, .button, .tab-button, input, .control-group, .sidebar-menu, .sidebar-menu--content, .menu-action, .button-arrow{ -webkit-transition: color 0.2s ease; -moz-transition: color 0.2s ease; -o-transition: color 0.2s ease; @@ -30,6 +30,130 @@ a, a:link, a:visited, a:focus, a:hover, a:active, button, .button, .tab-button, -ms-transition: all 0.1s ease; transition: all 0.1s ease; } +/******************** +* TACHYONS * +********************/ + +.tm-red{ + color:#e0474c; +} +.bg-tm-red{ + background:#e0474c; +} +.b--tm-red{ + border-color: #e0474c; +} +.hover-tm-red:hover{ + color:#e0474c; +} +.hover-bg-tm-red:hover{ + background:#e0474c; +} +.hover-b--tm-red:hover{ + border-color:#e0474c; +} +.tm-green{ + color:#66b0a3; +} +.bg-tm-green{ + background:#66b0a3; +} +.b--tm-green{ + border-color:#66b0a3; +} +.hover-tm-green:hover{ + color:#66b0a3; +} +.hover-bg-tm-green:hover{ + background:#66b0a3; +} +.hover-b--tm-green:hover{ + border-color:#66b0a3; +} +.w-100{ + width:100%!important; +} +.w-15{ + width: 15%; +} +.w-29{ + width: 29%; +} +.w-95{ + width:95%; +} +.w6{ + width: 22rem; +} +.h0{ + height: 0; +} +.h6{ + height: 22rem; +} +.mw6{ + max-width:22rem; +} +.max-h6{ + max-height: 22rem; +} +.shadow-tm{ + box-shadow: 0 0 2px 1px rgba(0,0,0,.1); +} +.bg-chess{ + background-image: linear-gradient(45deg, #808080 25%, transparent 25%), linear-gradient(-45deg, #808080 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #808080 75%), linear-gradient(-45deg, transparent 75%, #808080 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; +} +input.upload{ + position: absolute; + top: 0; + right: 0; + margin: 0; + padding: 0; + font-size: 20px; + cursor: pointer; + opacity: 0; +} +.top-3{ + top: 3rem; +} +.list-enter-active, .list-leave-active { + transition: all .3s; +} +.list-enter, .list-leave-to /* .list-leave-active below version 2.1.8 */ { + opacity: 0; +} + +/**************************** +* download-commponent * +****************************/ + +a.tm-download +{ + line-height: 35px; + margin-left: 40px; +} +a.tm-download::before{ + content: '\2193'; + position: absolute; + margin-left: -40px; + width: 30px; + height: 30px; + line-height: 30px; + font-family: "Comic Sans MS",cursive,sans-serif; + border: 2px solid #e0474c; + border-radius: 50%; + text-align: center; + text-decoration: underline; +} +a.tm-download:hover::before{ + text-decoration:underline; + color: #fff; + background: #e0474c; +} + + /******************** * COMMONS * ********************/ @@ -453,8 +577,7 @@ footer{ bottom: 0; left: 0; right: 0; - background: #FFF; - background: rgba(255,255,255,0.9); + background: rgba(0,0,0,0.4); } .modal{ display: none; @@ -1526,14 +1649,14 @@ label .help, .label .help{ } .blox-buttons button.edit{ background: #70c1b3; - color: #eee; + color: #fff; } .blox-buttons button.edit:hover{ background: #4D978A; } .blox-buttons button.cancel:hover{ background: #e0474c; - color: #eee; + color: #fff; } .blox-buttons button.edit:disabled, .blox-buttons button.cancel:disabled{ background: #eee; @@ -1750,6 +1873,9 @@ button.post-button:hover{ width: 0.7142857142857142em; margin-right: 10px; } +.icon-upload { + width: 0.9285714285714285em; +} .format-bar .hidden{ display: none; } @@ -1764,7 +1890,7 @@ button.post-button:hover{ .format-bar .editactive{ position: relative; margin-left: -20px; - margin-right: 20px; + margin-right: -20px; } .format-bar.blox{ width: auto; @@ -2121,6 +2247,25 @@ button.delDL i:hover{ .blox blockquote p{ margin-left: 50px; } +.imageupload{ + width: 50%; + position: relative; + display: inline-block; + border-right: 1px dotted grey; + box-sizing:border-box; +} +.imageselect{ + width: 50%; + position: relative; + display: inline-block; + box-sizing:border-box; + border:0px; + padding: 0 0 0 0; + margin: 0 0 0 0; + min-height: 70px; + background: #f9f8f6; + font-family: Helvetica, Calibri, Arial, sans-serif; +} .dropbox{ min-height: 70px; background: #f9f8f6; @@ -2212,6 +2357,22 @@ button.delDL i:hover{ border-color: transparent transparent transparent rgba(255, 255, 255, 0.75); content: ' '; } +.medialib{ + margin: auto; + width: 100%; + height: 80%; + overflow: auto; + background: #f9f8f6; + max-width: 1200px; +} +.imagecard{ + margin: 10px; + box-shadow:0 2px 5px rgba(22,23,26,.05); + display: inline-block; + vertical-align: top; + background: #fff; +} + sup{} cite{} abbr{} diff --git a/system/author/js/author.js b/system/author/js/author.js index 0c85f9c..372e401 100644 --- a/system/author/js/author.js +++ b/system/author/js/author.js @@ -125,7 +125,7 @@ /********************************** ** START VERSION CHECK ** **********************************/ - + if(document.getElementById("system")) { getVersions('system', document.getElementsByClassName("fc-system-version")); @@ -243,7 +243,6 @@ } return segmentsA.length - segmentsB.length; } - /************************************* ** CARDS: ACTIVATE/OPEN CLOSE ** @@ -269,6 +268,38 @@ } } + /************************************* + ** Input Type File ** + *************************************/ + + var fileinputs = document.querySelectorAll( ".fileinput" ); + + for (i = 0; i < fileinputs.length; ++i) + { + (function () { + + thisfileinput = fileinputs[i]; + + var deletefilebutton = thisfileinput.getElementsByClassName("deletefilebutton")[0]; + var deletefileinput = thisfileinput.getElementsByClassName("deletefileinput")[0]; + var visiblefilename = thisfileinput.getElementsByClassName("visiblefilename")[0]; + var hiddenfile = thisfileinput.getElementsByClassName("hiddenfile")[0]; + + hiddenfile.onchange = function() + { + visiblefilename.value = this.files[0].name; + } + + deletefilebutton.onclick = function(event) + { + event.preventDefault(); + deletefileinput.value = 'delete'; + visiblefilename.value = ''; + } + + }()); + } + /************************************* ** COLOR PICKER ** *************************************/ diff --git a/system/author/js/vue-blox-config.js b/system/author/js/vue-blox-config.js index a64aac5..170b501 100644 --- a/system/author/js/vue-blox-config.js +++ b/system/author/js/vue-blox-config.js @@ -42,6 +42,13 @@ let determiner = { } return false; }, + file: function(block,lines,firstChar,secondChar,thirdChar){ + if( (firstChar == '[' && lines[0].indexOf('{.tm-download') != -1) ) + { + return "file-component"; + } + return false; + }, code: function(block,lines,firstChar,secondChar,thirdChar){ if( firstChar == '`' && secondChar == '`' && thirdChar == '`') { @@ -67,6 +74,7 @@ let bloxFormats = { quote: { label: '', title: 'Quote', component: 'quote-component' }, image: { label: '', title: 'Image', component: 'image-component' }, video: { label: '', title: 'Video', component: 'video-component' }, + file: { label: '', title: 'File', component: 'file-component' }, toc: { label: '', title: 'Table of Contents', component: 'toc-component' }, hr: { label: '', title: 'Horizontal Line', component: 'hr-component' }, definition: { label: '', title: 'Definition List', component: 'definition-component' }, diff --git a/system/author/js/vue-blox.js b/system/author/js/vue-blox.js index 9a5b6fc..a64347b 100644 --- a/system/author/js/vue-blox.js +++ b/system/author/js/vue-blox.js @@ -5,9 +5,9 @@ const contentComponent = Vue.component('content-block', { template: '
' + '
Choose a content-type
' + '
' + - '
' + - '' + - '' + + '
' + + '' + + '' + '
' + '
' + '
' + @@ -40,6 +40,10 @@ const contentComponent = Vue.component('content-block', { eventBus.$on('closeComponents', this.closeComponents); }, methods: { + disableSort: function(event) + { + this.$root.$data.sortdisabled = true; + }, addNewBlock: function(event) { /* we have to get from dom because block-data might not be set when user clicked on add button before opened the component */ @@ -76,9 +80,27 @@ const contentComponent = Vue.component('content-block', { updateMarkdown: function($event) { this.compmarkdown = $event; - this.$nextTick(function () { - this.$refs.preview.style.minHeight = this.$refs.component.offsetHeight + 'px'; - }); + this.setComponentSize(); + }, + setComponentSize: function() + { + if(this.componentType == 'image-component') + { + myself = this; + setTimeout(function(){ + myself.$nextTick(function () + { + myself.$refs.preview.style.minHeight = myself.$refs.component.offsetHeight + 'px'; + }); + }, 200); + } + else + { + this.$nextTick(function () + { + this.$refs.preview.style.minHeight = this.$refs.component.offsetHeight + 'px'; + }); + } }, switchToEditMode: function() { @@ -91,22 +113,7 @@ const contentComponent = Vue.component('content-block', { this.edit = true; /* show the edit-mode */ this.compmarkdown = self.$root.$data.blockMarkdown; /* get markdown data */ this.componentType = self.$root.$data.blockType; /* get block-type of element */ - if(this.componentType == 'image-component') - { - setTimeout(function(){ - self.$nextTick(function () - { - self.$refs.preview.style.minHeight = self.$refs.component.offsetHeight + 'px'; - }); - }, 200); - } - else - { - this.$nextTick(function () - { - this.$refs.preview.style.minHeight = self.$refs.component.offsetHeight + 'px'; - }); - } + this.setComponentSize(); }, closeComponents: function() { @@ -216,7 +223,8 @@ const contentComponent = Vue.component('content-block', { { var compmarkdown = this.compmarkdown.split('\n\n').join('\n'); } -*/ var compmarkdown = this.compmarkdown; +*/ + var compmarkdown = this.compmarkdown; var params = { 'url': document.getElementById("path").value, @@ -228,13 +236,33 @@ const contentComponent = Vue.component('content-block', { if(this.componentType == 'image-component' && self.$root.$data.file) { - var url = self.$root.$data.root + '/api/v1/image'; + var url = self.$root.$data.root + '/api/v1/image'; var method = 'PUT'; + params.new = false; + if(self.$root.$data.newblock || self.$root.$data.blockId == 99999) + { + params.new = true; + } } else if(this.componentType == 'video-component') { - var url = self.$root.$data.root + '/api/v1/video'; - var method = 'POST'; + var url = self.$root.$data.root + '/api/v1/video'; + var method = 'POST'; + params.new = false; + if(self.$root.$data.newblock || self.$root.$data.blockId == 99999) + { + params.new = true; + } + } + else if(this.componentType == 'file-component') + { + var url = self.$root.$data.root + '/api/v1/file'; + var method = 'PUT'; + params.new = false; + if(self.$root.$data.newblock || self.$root.$data.blockId == 99999) + { + params.new = true; + } } else if(self.$root.$data.newblock || self.$root.$data.blockId == 99999) { @@ -246,7 +274,7 @@ const contentComponent = Vue.component('content-block', { var url = self.$root.$data.root + '/api/v1/block'; var method = 'PUT'; } - + sendJson(function(response, httpStatus) { if(httpStatus == 400) @@ -1180,12 +1208,21 @@ const videoComponent = Vue.component('video-component', { }, }) + const imageComponent = Vue.component('image-component', { props: ['compmarkdown', 'disabled'], template: '
' + '' + - ' ' + - '

{{ $t(\'drag a picture or click to select\') }}

' + + '
' + + ' ' + + '

{{ $t(\'drag a picture or click to select\') }}

' + + '
' + + '' + + '' + + '' + + '' + '
' + '' + '
' + @@ -1201,6 +1238,7 @@ const imageComponent = Vue.component('image-component', { return { maxsize: 5, // megabyte imgpreview: false, + showmedialib: false, load: false, imgmeta: false, imgalt: '', @@ -1209,7 +1247,7 @@ const imageComponent = Vue.component('image-component', { imglink: '', imgclass: 'center', imgid: '', - imgfile: 'imgplchldr', + imgfile: '', } }, mounted: function(){ @@ -1280,6 +1318,10 @@ const imageComponent = Vue.component('image-component', { } }, methods: { + openmedialib: function() + { + this.showmedialib = true; + }, isChecked: function(classname) { if(this.imgclass == classname) @@ -1372,7 +1414,7 @@ const imageComponent = Vue.component('image-component', { errors = 'Maximum size of image caption is 140 characters'; } } - + if(errors) { this.$parent.freezePage(); @@ -1402,17 +1444,17 @@ const imageComponent = Vue.component('image-component', { } else { - self = this; - this.$parent.freezePage(); - this.$root.$data.file = true; - this.load = true; + self = this; + self.$parent.freezePage(); + self.$root.$data.file = true; + self.load = true; + let reader = new FileReader(); reader.readAsDataURL(imageFile); reader.onload = function(e) { + self.imgpreview = e.target.result; - self.$emit('updatedMarkdown', '![](imgplchldr)'); - /* load image to server */ var url = self.$root.$data.root + '/api/v1/image'; @@ -1420,22 +1462,23 @@ const imageComponent = Vue.component('image-component', { var params = { 'url': document.getElementById("path").value, 'image': e.target.result, + 'name': imageFile.name, 'csrf_name': document.getElementById("csrf_name").value, 'csrf_value': document.getElementById("csrf_value").value, }; - + var method = 'POST'; - + sendJson(function(response, httpStatus) - { + { if(httpStatus == 400) { - self.$parent.activatePage(); + self.activatePage(); } if(response) { - self.$parent.activatePage(); self.load = false; + self.$parent.activatePage(); var result = JSON.parse(response); @@ -1446,6 +1489,200 @@ const imageComponent = Vue.component('image-component', { else { self.imgmeta = true; + self.imgfile = result.name; + self.$emit('updatedMarkdown', '![]('+ result.name +')'); + } + } + }, method, url, params); + } + } + } + } + } +}) + +const fileComponent = Vue.component('file-component', { + props: ['compmarkdown', 'disabled'], + template: '
' + + '' + + '
' + + ' ' + + '

{{ $t(\'upload file\') }}

' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
', + data: function(){ + return { + maxsize: 5, // megabyte + showmedialib: false, + load: false, + filemeta: false, + filetitle: '', + fileextension: '', + fileurl: '', + fileid: '' + } + }, + mounted: function(){ + + this.$refs.markdown.focus(); + + if(this.compmarkdown) + { + this.filemeta = true; + + var filemarkdown = this.compmarkdown; + + var filetitle = filemarkdown.match(/\[.*?\]/); + if(filetitle) + { + filemarkdown = filemarkdown.replace(filetitle[0],''); + this.filetitle = filetitle[0].slice(1,-1); + } + + var fileattr = filemarkdown.match(/\{.*?\}/); + var fileextension = filemarkdown.match(/file-(.*)?\}/); + if(fileattr && fileextension) + { + filemarkdown = filemarkdown.replace(fileattr[0],''); + this.fileextension = fileextension[1].trim(); + } + + var fileurl = filemarkdown.match(/\(.*?\)/g); + if(fileurl) + { + filemarkdown = filemarkdown.replace(fileurl[0],''); + this.fileurl = fileurl[0].slice(1,-1); + } + } + }, + methods: { + openmedialib: function() + { + this.showmedialib = true; + }, + isChecked: function(classname) + { + if(this.fileclass == classname) + { + return ' checked'; + } + }, + updatemarkdown: function(event) + { + this.$emit('updatedMarkdown', event.target.value); + }, + createmarkdown: function() + { + var errors = false; + + if(this.filetitle.length < 101) + { + filemarkdown = '[' + this.filetitle + ']'; + } + else + { + errors = 'Maximum size of file-text is 100 characters'; + filemarkdown = '[]'; + } + if(this.fileurl != '') + { + if(this.fileurl.length < 101) + { + filemarkdown = '[' + this.filetitle + '](' + this.fileurl + ')'; + } + else + { + errors = 'Maximum size of file link is 100 characters'; + } + } + if(this.fileextension != '') + { + filemarkdown = filemarkdown + '{.tm-download file-' + this.fileextension + '}'; + } + if(errors) + { + this.$parent.freezePage(); + publishController.errors.message = errors; + } + else + { + publishController.errors.message = false; + this.$parent.activatePage(); + this.$emit('updatedMarkdown', filemarkdown); + } + }, + onFileChange: function( e ) + { + if(e.target.files.length > 0) + { + let uploadedFile = e.target.files[0]; + let size = uploadedFile.size / 1024 / 1024; + + if (size > this.maxsize) + { + publishController.errors.message = "The maximal size of a file is " + this.maxsize + " MB"; + } + else + { + self = this; + + self.$parent.freezePage(); + self.$root.$data.file = true; + self.load = true; + + let reader = new FileReader(); + reader.readAsDataURL(uploadedFile); + reader.onload = function(e) { + + /* load file to server */ + var url = self.$root.$data.root + '/api/v1/file'; + + var params = { + 'url': document.getElementById("path").value, + 'file': e.target.result, + 'name': uploadedFile.name, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + }; + + var method = 'POST'; + + sendJson(function(response, httpStatus) + { + if(httpStatus == 400) + { + self.activatePage(); + } + if(response) + { + self.load = false; + self.$parent.activatePage(); + + var result = JSON.parse(response); + + if(result.errors) + { + publishController.errors.message = result.errors; + } + else + { + self.filemeta = true; + self.filetitle = result.info.title; + self.fileextension = result.info.extension; + self.fileurl = result.info.url; + self.createmarkdown(); } } }, method, url, params); @@ -1456,6 +1693,493 @@ const imageComponent = Vue.component('image-component', { } }) +const medialib = Vue.component('medialib', { + props: ['parentcomponent'], + template: '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
{{errors}}
' + + '' + + '
' + + '' + + ' click to select' + + '' + + '
' + + '
{{ image.name }}
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
Name
{{ imagedetaildata.name}}
' + + '
URL
{{ imagedetaildata.src_live}}
' + + '
' + + '
' + + '
Size
{{ getSize(imagedetaildata.bytes) }}
' + + '
' + + '
' + + '
Dimensions
{{ imagedetaildata.width }}x{{ imagedetaildata.height }} px
' + + '
' + + '
' + + '
Type
{{ imagedetaildata.type }}
' + + '
' + + '
' + + '
Date
{{ getDate(imagedetaildata.timestamp) }}
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '

Image used in:

' + + '' + + '
No pages found.
'+ + '
' + + '
' + + '' + + '
' + + '' + + '
' + + '' + + '
' + + '
' + + '
{{ file.name }}
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
{{ filedetaildata.info.extension }}
' + + '
' + + '
' + + '
' + + '
Name
{{ filedetaildata.name}}
' + + '
URL
{{ filedetaildata.url}}
' + + '
' + + '
' + + '
Size
{{ getSize(filedetaildata.bytes) }}
' + + '
' + + '
' + + '
Type
{{ filedetaildata.info.extension }}
' + + '
' + + '
' + + '
Date
{{ getDate(filedetaildata.timestamp) }}
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '

File used in:

' + + '' + + '
No pages found.
'+ + '
' + + '
' + + '
' + + '
' + + '
', + data: function(){ + return { + imagedata: false, + showimages: true, + imagedetaildata: false, + showimagedetails: false, + filedata: false, + showfiles: false, + filedetaildata: false, + showfiledetails: false, + detailindex: false, + load: false, + baseurl: false, + search: '', + errors: false, + } + }, + mounted: function(){ + + if(this.parentcomponent == 'files') + { + this.showFiles(); + } + + this.errors = false; + var self = this; + + myaxios.get('/api/v1/medialib/images',{ + params: { + 'url': document.getElementById("path").value, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + self.imagedata = response.data.images; + }) + .catch(function (error) + { + if(error.response) + { + self.errors = error.response.data.errors; + } + }); + }, + computed: { + filteredImages() { + + var searchimages = this.search; + var filteredImages = {}; + var images = this.imagedata; + Object.keys(images).forEach(function(key) { + var searchindex = key + ' ' + images[key].name; + if(searchindex.toLowerCase().indexOf(searchimages.toLowerCase()) !== -1) + { + filteredImages[key] = images[key]; + } + }); + return filteredImages; + }, + filteredFiles() { + + var searchfiles = this.search; + var filteredFiles = {}; + var files = this.filedata; + Object.keys(files).forEach(function(key) { + var searchindex = key + ' ' + files[key].name; + if(searchindex.toLowerCase().indexOf(searchfiles.toLowerCase()) !== -1) + { + filteredFiles[key] = files[key]; + } + }); + return filteredFiles; + } + }, + methods: { + isImagesActive: function() + { + if(this.showimages) + { + return 'bg-tm-green white'; + } + return 'bg-light-gray black'; + }, + isFilesActive: function() + { + if(this.showfiles) + { + return 'bg-tm-green white'; + } + return 'bg-light-gray black'; + }, + closemedialib: function() + { + this.$parent.showmedialib = false; + }, + getBackgroundImage: function(image) + { + return 'background-image: url(' + image.src_thumb + ');width:250px'; + }, + showImages: function() + { + this.errors = false; + this.showimages = true; + this.showfiles = false; + this.showimagedetails = false; + this.showfiledetails = false; + this.imagedetaildata = false; + this.detailindex = false; + }, + showFiles: function() + { + this.showimages = false; + this.showfiles = true; + this.showimagedetails = false; + this.showfiledetails = false; + this.imagedetaildata = false; + this.filedetaildata = false; + this.detailindex = false; + + if(!this.files) + { + this.errors = false; + var filesself = this; + + myaxios.get('/api/v1/medialib/files',{ + params: { + 'url': document.getElementById("path").value, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + filesself.filedata = response.data.files; + }) + .catch(function (error) + { + if(error.response) + { + filesself.errors = error.response.data.errors; + } + }); + } + }, + showImageDetails: function(image,index) + { + this.errors = false; + this.showimages = false; + this.showfiles = false; + this.showimagedetails = true; + this.detailindex = index; + this.baseurl = myaxios.defaults.baseURL + '/tm/content/visual'; + + var imageself = this; + + myaxios.get('/api/v1/image',{ + params: { + 'url': document.getElementById("path").value, + 'name': image.name, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + imageself.imagedetaildata = response.data.image; + }) + .catch(function (error) + { + if(error.response) + { + imageself.errors = error.response.data.errors; + } + }); + }, + showFileDetails: function(file,index) + { + this.errors = false; + this.showimages = false; + this.showfiles = false; + this.showimagedetails = false; + this.showfiledetails = true; + this.detailindex = index; + + this.baseurl = myaxios.defaults.baseURL + '/tm/content/visual'; + + var fileself = this; + + myaxios.get('/api/v1/file',{ + params: { + 'url': document.getElementById("path").value, + 'name': file.name, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + fileself.filedetaildata = response.data.file; + }) + .catch(function (error) + { + if(error.response) + { + fileself.errors = error.response.data.errors; + } + }); + }, + selectImage: function(image) + { + this.showImages(); + + if(this.parentcomponent == 'images') + { + var imgmarkdown = {target: {value: '![alt]('+ image.src_live +')' }}; + + this.$parent.imgfile = image.src_live; + this.$parent.imgpreview = image.src_live; + this.$parent.imgmeta = true; + + this.$parent.showmedialib = false; + + this.$parent.updatemarkdown(imgmarkdown); + } + if(this.parentcomponent == 'files') + { + var filemarkdown = {target: {value: '[' + image.name + '](' + image.src_live +'){.tm-download}' }}; + + this.$parent.filemeta = true; + this.$parent.filetitle = image.name; + + this.$parent.showmedialib = false; + + this.$parent.updatemarkdown(filemarkdown); + } + }, + selectFile: function(file) + { + /* if image component is open */ + if(this.parentcomponent == 'images') + { + var imgextensions = ['png','jpg', 'jpeg', 'gif', 'svg']; + if(imgextensions.indexOf(file.info.extension) == -1) + { + this.errors = "you cannot insert a file into an image component"; + return; + } + var imgmarkdown = {target: {value: '![alt]('+ file.url +')' }}; + + this.$parent.imgfile = file.url; + this.$parent.imgpreview = file.url; + this.$parent.imgmeta = true; + + this.$parent.showmedialib = false; + + this.$parent.updatemarkdown(imgmarkdown); + } + if(this.parentcomponent == 'files') + { + var filemarkdown = {target: {value: '['+ file.name +']('+ file.url +'){.tm-download file-' + file.info.extension + '}' }}; + + this.$parent.showmedialib = false; + + this.$parent.filemeta = true; + this.$parent.filetitle = file.info.filename + ' (' + file.info.extension.toUpperCase() + ')'; + + this.$parent.updatemarkdown(filemarkdown); + } + this.showFiles(); + }, + removeImage: function(index) + { + this.imagedata.splice(index,1); + }, + removeFile: function(index) + { + this.filedata.splice(index,1); + }, + deleteImage: function(image, index) + { + imageself = this; + + myaxios.delete('/api/v1/image',{ + data: { + 'url': document.getElementById("path").value, + 'name': image.name, + 'index': index, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + imageself.showImages(); + imageself.removeImage(index); + }) + .catch(function (error) + { + if(error.response) + { + imageself.errors = error.response.data.errors; + } + }); + }, + deleteFile: function(file, index) + { + fileself = this; + + myaxios.delete('/api/v1/file',{ + data: { + 'url': document.getElementById("path").value, + 'name': file.name, + 'index': index, + 'csrf_name': document.getElementById("csrf_name").value, + 'csrf_value': document.getElementById("csrf_value").value, + } + }) + .then(function (response) + { + fileself.showFiles(); + fileself.removeFile(index); + }) + .catch(function (error) + { + if(error.response) + { + fileself.errors = error.response.data.errors; + } + }); + }, + getDate(timestamp) + { + date = new Date(timestamp * 1000); + + datevalues = { + 'year': date.getFullYear(), + 'month': date.getMonth()+1, + 'day': date.getDate(), + 'hour': date.getHours(), + 'minute': date.getMinutes(), + 'second': date.getSeconds(), + }; + return datevalues.year + '-' + datevalues.month + '-' + datevalues.day; + }, + getSize(bytes) + { + var i = Math.floor(Math.log(bytes) / Math.log(1024)), + sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + return (bytes / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + sizes[i]; + }, + isChecked: function(classname) + { + if(this.imgclass == classname) + { + return ' checked'; + } + }, + }, +}) + + let activeFormats = []; for(var i = 0; i < formatConfig.length; i++) @@ -1576,11 +2300,13 @@ let editor = new Vue({ }, method, url, params); }, methods: { - onStart: function(evt) + onStart: function() { + }, moveBlock: function(evt) { + var params = { 'url': document.getElementById("path").value, 'old_index': evt.oldIndex, diff --git a/system/author/layouts/layout.twig b/system/author/layouts/layout.twig index 79f6800..67650ad 100644 --- a/system/author/layouts/layout.twig +++ b/system/author/layouts/layout.twig @@ -17,6 +17,7 @@ + diff --git a/system/author/layouts/layoutBlox.twig b/system/author/layouts/layoutBlox.twig index 2d9b7e8..ab88393 100644 --- a/system/author/layouts/layoutBlox.twig +++ b/system/author/layouts/layoutBlox.twig @@ -17,6 +17,7 @@ + @@ -68,14 +69,20 @@ {{ __('FOLDER') }} - - + + {{ __('UPLOAD') }} + + {{ __('IMAGE') }} + + {{ __('PAPERCLIP') }} + + {{ __('VIDEO') }} @@ -142,12 +149,31 @@ {{ __('CROSS') }} + + {{ __('TRASH') }} + + + + {{ __('INFO') }} + + + + - eye-blocked + {{ __('EYE_BLOCKED') }} - + + + {{ __('SEARCH') }} + + + + {{ __('CANCEL') }} + + + {{ assets.renderSvg() }} diff --git a/system/author/settings/system.twig b/system/author/settings/system.twig index da810db..424a954 100644 --- a/system/author/settings/system.twig +++ b/system/author/settings/system.twig @@ -10,7 +10,7 @@
-
+
@@ -63,6 +63,34 @@
+
+ +
+ + + +
+ {{ __('BROWSE') }} + +
+
+ {% if errors.settings.favicon %} + {{ errors.settings.favicon | first }} + {% endif %}

@@ -102,5 +130,4 @@
- {% endblock %} diff --git a/system/system.php b/system/system.php index c01da87..da86c2d 100644 --- a/system/system.php +++ b/system/system.php @@ -199,8 +199,9 @@ $container['view'] = function ($container) $view->addExtension(new Slim\Views\TwigExtension($container['router'], $basePath)); $view->addExtension(new Twig_Extension_Debug()); $view->addExtension(new Typemill\Extensions\TwigUserExtension()); - $view->addExtension(new Typemill\Extensions\TwigMarkdownExtension()); + $view->addExtension(new Typemill\Extensions\TwigMarkdownExtension()); $view->addExtension(new Typemill\Extensions\TwigMetaExtension()); + $view->addExtension(new Typemill\Extensions\TwigPagelistExtension()); // i18n $view->addExtension(new Typemill\Extensions\TwigLanguageExtension( $container->get('settings')['labels'] )); diff --git a/themes/typemill/cover.twig b/themes/typemill/cover.twig index c5a3cd0..3e6c3eb 100644 --- a/themes/typemill/cover.twig +++ b/themes/typemill/cover.twig @@ -2,7 +2,15 @@ {% block content %} -

{{ title }}

+ {% if logo and settings.themes.typemill.coverlogo %} + + + + {% else %} + +

{{ title }}

+ + {% endif %}
diff --git a/themes/typemill/css/style.css b/themes/typemill/css/style.css index 13ce32f..cb01334 100644 --- a/themes/typemill/css/style.css +++ b/themes/typemill/css/style.css @@ -578,6 +578,41 @@ cite{} abbr{} hr{} +img.logo{ + width: 100%; +} +img.coverlogo{ + margin-top: 4em; +} +/**************************** +* download-commponent * +****************************/ + +a.tm-download +{ + line-height: 35px; + margin-left: 40px; +} +a.tm-download::before{ + content: '\2193'; + position: absolute; + margin-left: -40px; + width: 30px; + height: 30px; + line-height: 30px; + font-family: "Comic Sans MS",cursive,sans-serif; + border: 2px solid #e0474c; + border-radius: 50%; + text-align: center; + text-decoration: underline; +} +a.tm-download:hover::before{ + text-decoration:underline; + color: #fff; + background: #e0474c; +} + + /************************ * TABLE OF CONTENTS * ************************/ diff --git a/themes/typemill/img/apple-touch-icon-144x144.png b/themes/typemill/img/apple-touch-icon-144x144.png deleted file mode 100644 index cb836ce1eae883a7f7ba761ed3ee28ab396a59e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10865 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 Ze~e?!hxZXt-{9MX diff --git a/themes/typemill/img/mstile-144x144.png b/themes/typemill/img/mstile-144x144.png deleted file mode 100644 index cb836ce1eae883a7f7ba761ed3ee28ab396a59e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10865 zcmX|nRa9I}6YU_ugTo+!2@>3$;I2V~yE_vs3=ScK6WoHkLvRMS!8N$M2Zvw*F5kcI zy$`3)ORYYqtE+17-qqo1Dspcz$S?o^z*_}*X^q!+?0*j$%ImlehvArDdB}Ka z#4;wbVF305?$NBz+HnedaeB|+fTeEH2ZB9PcC4`bW!|tQDeZIJ@r}Voyjze{W$LaV z=7JIuH;~esZZ&p?b_TUcF9N?`G3>bx9&%(yM8ctggxvP z2U-(sNa{6Lb>Rg&&{+M{srzKGBAyUEp;kWUC5+%wwObIWF<*O_UbW0;T!p^h1B~Oo z=iyO0b|TBfClT1PCirhbH#(iNFH77UUrq%XuyBfx0}F*S!T711y^;?w<_#-W09Ko^ z)CqrjcZ5u$c4hqTVfZG-FQYb=df`DRY&2j{8M7OyhlhK-+tP47<7d~-C;t4)NE6e8 zOkl3f7`iysx8G#R65RDh2{znkib~6-1D)~Xm4^$G( zm=?Xj6l!_wGMX1xx;%=0nF39pBe9xoOcTj7yJGXZyr%qL`&od3uHRpH6>y3e!*~?H5HqIN+ zkWzwCsi}3Z>`c}jQ3$&~{V;ttGO>*OL@TsX*4Dn*#~96+8M*o3Eozs=XUdiD;SL>< z58R#fMpofO$(O?gVD*sq9m01jY@>aT^?aDwEwVaiuCoZSS_r5|22ExIFArSasy;*^ z>wcUVG=~fFQ|{<24$%17+rpmm@aYx7kfGNJ7eQhG<4UFm*WW{VpsisiU6AM?B8+EI zW?`V@icBe}UNrpgOo7El!$<407V=p+Q1AKUdgz)?)&W+c6tYbfkKt}rl5*<>0D%43 z{~ou$!~et4geX0n#_*?Z6}`Hu@k0j`3ohWreU9mB=4Fuqtq{QSLo9w2Gc9?!P+f4j zCpz#sap1VzPoX{n0lE@!CEwtnbT(S~CAS`0dfB%IA+&MDT$ESC|74PPIe0AIl4Qt) zU)p{cp066CPT%DNZ}xh!pbXb)B$G|r=Nsuth8~&|F(S4t_Ko(ps^77!Od(HWpt9u*s8F7|8ksf`;F=U)BG(t#ec z{&bZ!JMd$JSxb5KpOoXv0)4?Hhn))vNkVcsQj`lvQ3VT5adw)YTVv;l&s^QcnhXYzkQoHJSe zD9d6`g387LG+U*I1$_IHuIL+rV341mhSPBx=8xvJu0KdP8!@0Kl0E(>joN}E1Fwfyz~O~7dVO|^ml3vAMtragO5kDCuRp#W z5ktq$;=6HjoR@32IqnNoc9=HdH6N4aGZS81InKisl#M-+iso0*c`QC~K2;yJh`yg? z%d|^lxb(V1XU*S^ht6}Vh27*>(bZI>4ghzV?>Vt+BP7d&OOaWXfRwH&jSmaezn;Ki z!D$8o-<$Q3G>9u30x|~)iU8O%S-$woVmErqtNy5ywu)ACnbVsf#^Oxn&8sVmNbo2Z z_vyuEu=)FxJeIkCO*1yq*6&^O`h(-Bfmz6#7dwRg9rvuGVwY&LYd(w-kEa$Vz)vSE z^Y{91xjm}}cHZ*SWTndA=v_@jjSn$r)DY_}q_Fw>J9hK6??vGF-4Va3<)t()s{4!U z7}Zc8?M^5@qD4y6X>>OuGv1y4?n-R^`-A3$>A~;FsoOHmn;-c5ZMzIzR!dUDPCd@2 z3qj!lFzr7K{6F)4)Y2M$QdTN1D7T^oZ(W_+UG8noYwz_mF~ovnuXGbw=B*2>Q;L-_ zGZ>4E`3Rvl8Q7>2g zY5{wmfEjIMC7!O$6M6bXui9-l+Ueo%x_KcVrCZAOO+lv^p5Cd{}u>= zSfg0z7)39E`7G{`&ZNvB^8M0fAAvWz(q*KQpHJj{Pxw3GKiGZIqs2t&8w9?s@mlNi zV!bi_>s!VuBs#&w;;`5e>pS_@ib8=0Up5in8ACc>iPtozj*8Jn)HkoRlvwwj#oy;g z_0?L^d{770D!a*t@f=#NT5fCi??^6DX&&U9&o|)rKgi_`lYU~@H5C{-GIem$Nz(Zv zl&%^O!CA6N@ztPgIGR=wq5=;SdXYWGK6$lv1V_tRQif@vT%W>~5C6pR=$Wq(DDxTf zWk4`EW|~AIPqOa_|AUf;`78u&rVq@b>nzBV85unbS~?b|Sw1pCeOh$VQO(iGj76k0 z4-|n-7u!RR+7VTWP#eR7#y9=l;pGNg(O2}-uB^)yVO@f{xq;8~?I^uxe*Age5_tfgye)m$v8q^f=F7R(3?G;dIc<-#*O!&) zt;s)ZEF(zJRZFQr8P*r_?<2hGbooMx}n%A|<=o$>+Dn|4h z#G5bNL%^~v-k8{+H~BN{_s7{dWSV{zmdwZzS+S8zJZM|IOvT0zLTIyBCqBLT}BWeA{N!6nH3wmi9I>06Bq0uQ7 zcEvU-@Yt%ENTq zmmd&Uvayo@NskfY(KR2jGJ{6ItiAne5=Ixo$yY%~R|O$1LGj;ZK`dR;ogwUA^eF4k zd@{UlF4hV6=2yz?Yw+3BYO|DhXi=MohA{jSPvcqFpOENO*s(KD)_NL?c|nrGH-}4> zcC?6smOe7fe!REQIWKRk<<^f)BDW&iyA%sQKQ=zQV!!`^H!+c0dk9Qzd#3x`eEsaCQfIA*-=; zMdkUrARZDO8N3A6RR1GjiV@4z!=fY{Pe@gP+HtKM>%;J$s(s#itBSmVT8#t_`t!SH zS@{~U;A-VeiZ8Y8S*$@l&90CG0O4YSa%~o<)^qF7G>_vc5)iP?aFtSV0k6E!(n<5< zAGIj|JN7A$4TMT8rHL)KS?jWwO`KOI1XK@r5R)MP${dTmANvk1vgZ~4`?~aO^4hF< z^E!+r*@?{P|Db#)>rlYW`@<;gCzZA;-Fl0>WESq2iNOjSrR|qD7I$-e$9+6!u_t#t znfmkh2!vDg$PQJa$)WS9W? zjCaIIam7iT`xvH0BK#tp7$bsG(Ym~-Liy~dJ=kZXS4k`kWL$89>PE%-(vR9PO$6=j zAQyp6LemVuQo`Kj!Roscuf-tB|GIkeDuP!6AwVrlsW2=(^=|DZwfWRqTHe`!NC3O! z4F^!dwT~3ir}pt5$7^j69UcIo&dtuwHN%U~F+Z)5Fw6|6Ix`}lxw&{P;oyCC8&Fa9 zdqGWIgv9t_{#*zl5pZCYecqZt&=zlvVsvV~SgBihzEI75UI7_68z{cRMT#V8!4^U8 z1sa8&@|N$qy-+ty52p+7k+K6j;Lt^LLLCeb7o|-87MuniXXn+3@H4~6`%j(XJOW|6 zDK8W)*bAVC?)ENHGHUWbq#Km}s(-}Rr_D4MA!Tgl9NN+sflnOn&t3;YH-tIOzxIWD z?+yc#Lg9$JpCU>BRIKx15{Br+oR^sLq}>2GUM71>#GZIiCj1E07z&bSBfk=;y$x^N z4|s4B4!8#f3aI=rCy{c2uaQ8m1P{cv-+H&c=j9Nuz4;|CxQ|Zmc<}=V>?agjJvu;A zX)&_=al?GYQ3zz*fLYRv#3hOl_)0i)ZEk8N%Sni)_uPTn2~~U_td`+^e`V_(k;gKH z;G*LP46ld~^jtTW$058zt*OXgePtiodS!4mXW0%KEKEyOFwkSLpnIEQ{uQ4+gkH}M zhB8HmWT3p!BZq{BOd=aCEZFM?89x~Vxo~2O5?XkuvAV|_^et9 ztYD#Vm_Gd2?$ph!-X!-_dpCcLGEvq0jrV+o#4TQVU`cF!G<I60h%Xveu8 z!%+~@@MB3+HTUscD(IW;O|#**G~a|H_;+wIFW3wAZ6dp6<>!f~c5T`~g)gNrp>cIY z;L*0K=_9>pbQ-7<35#^OBHeYie$?izr-!gs#n)XoG2ottK%FKVbXY?z<#8yoxb)&5 zwzA(D%INbQLSK?#V$4S};k`MZhFXoa@LJ|gKa?(hHqkchJR`OtYce}nB~ph9j3p&H zE1I1=OX*hH3po(tPs#0BF))JzmT+@4T%8H(2fZM#l>{fXNK?4VS^TW>n_JvT>`%}c zA}JNQ28I-0x_|NocCz_S@yQcfGAYU9lE;H>#OYfxoMgA?_WYmEQrq@E&NMo|iG+*B zYQ#gl$)auiLx1D3gYk6HUPOJ5yjgU-NVP?9p5PmrY-=0vl-te7dUc&%y&6@@D;uVY z6Mf1S!~_PF-PhCa(a&RbuRrP{fqNpq+q7kNHwW3R?hDLXGQgS3?(DPvILv zY|^)Q?4DUJtXgY(3X<_SU=K4kZrdVUpxhyJ$B8&5Ts^Kyh{N`!%hKQ#fzF!DFZ!zDw4CAIYOq^hdg?c7$pE08qxjgFuSMZQd?OKNC3zvPD*qeU2|jq`kc)iqW%4rn5-V7vN;mpYj~T&Q&mXT;=#sq%?@5S#lXLXWo$y zth^F0?^W-aY(H(WU^uK^kVQ|c(2nk7VMs<$?&Ba0=ffsWUh{-#>0e)hF!KXZf}fv0 zQ+ksKz1)gXt-++qlv$FGCt{20@~E__U#f^?{msevDDyrm9sVbzmD8*DQ&@2k{(Du*k+{tHs4|Mn zk|tCU`YMSR^7X*WI5|WkBSy-Zl*of^xo^nTM6R}+&mjAi#6n}W&U33S3){m_17}VnvJXhrU zu>!?(dSBeOdhjvrG~zwVazDg4Wo`*Y96Ftm+VyAk*8FdkkJ@_U+c1lfyP9iLU0b?2 z7E%uH+uZ(dG)HXtkLSH=qMMLJ9Z*cqIj$~@1??A_I#^G)O_Atk9V~S-CCB-4uwC+e zq|jG)8RxL2L7*2-T#r?VnJ-*=R^amGSC#XtC(-x^2oCGR>~$gaH4&!{$gl`DyR za{x0jZjujfG*dKijs*67~xtb$*+ycMr3J99%3Y<^3Lc2Xp6oW-_k z{8`-P-u3!dw(1I|BCGz);k9~``Y^u?U5+Ot z6%e@hHjPCbsyYy*O9Y_ltDCSZmSfAUm#a#7joDu)cVNtip`)Sm`pz*)lWN`pk6;`v z-i!A*Ch*Z!&q$h3;*e>EjC2T7kP6|+b9xNN?nP;Qv2BhM{|RwbdD9aMrP?uqE@f9+~wuOG4Y*{9tN7GfPW zIYD>LxpcDrR;r^;SO4Q*4iW)x@)A}bagd7H1xA(YmVS@BHHw&exyO^13Pv|3Nc6zK zdzcU<@bSOg4hum6R5^Z7owBLzV58?M_EFJ;d60&wX>WtxMxa>gtO-10Fj9Ueh2Xf|Jms5hpp|xsC)f0yBkI zeHKojH7_HP^17+fy@)Z9Jv^6kH&s0>J{FXRZjcfvG6l0y7TU;rU)B$ciJ-@?>%Bal z$CX+Y{gP}*VA7BmIq9MbmLnxBFaJ1U#_qWrd~Vpvio#~xZorVUKa)_ig_#Xx*HuGQ zy2zk?DCO-ZiD1??t?qmgeozT$U(wU9K38I4+uo^XEwt^M_+4ac+;6>Q)U7NX;^I0K z1+5hZP_WOxP~B0_juTVqnS-wMlM64*zG*XCZnN0hkp=>pGj;ozO~X)7IUt+&Bm$4h z(ndz(oQ{Vvzu)e@8L^5o{wv{LEsRF6{*>k0-K?fvgSbOBkvOXaCt;ale$^Rghyxcu zxDRx}0gJ8?%du+T=yV*3zu`RCUr7e<8oD1wDd+#Whi{O6XAUD#45j=QciRbEm1}$V zky@j>p%AC^PKKG4iX7ny{E-|UjNNxPUnTwI*%>N29&4IKJ%s=4xN3`MMh4!dXHMjh z!X{%_zU~fL0m5hU~R-vdwi3^NCr<7Eu!dnI~v5jDwMNP5Wb!^lC0W_*D<~^=sl%}9N=_GWE_J@R*e9ltSWUspnON?55x+Gc z$4rYnn06~V22S@FZ|>1xuNOvm-_T{ph}n<1s{kHF2&2Bvx>UsK+{Y)pHJ*}W3wLU6 zUrE{@I&-gpcMn@(8GfNu46W$9TZ) zhtb7*Y*>`Z^(P(HD9)PZG-DW_zE^mN(_NmUm4OxJNhKTHOVV;6d0j0+QNjFlDy6B* zXHvc7ezOkUH*|bXGI2rty8LPcS z@29Bm2-E0IbsB%d2o!eIpwAa>QG|&@=9Ju94tfJQ?XTL)LA9PABC1#&fvJp;=eE zlH`?{yvV`VHntM!swJ1CB%GUMFQvyo5zmo`Er>QE_GpJ?bjK&{=eN{Q_)T}uziN!v z2N*o>h2jqK+2xobzgI_(`&t%Nggg*$vYxhk?O!3 zYvrHNwhR}qav(*WoP`F}&6ywMVZx)$uqD(wd`})&jwH|H2>{|eT;Hc+3`0)yV|rdU z{S`PAPJD|G7e}tnx0&jqBXOjz=A!#JQY;2+()w4fOG)`7ZraB(g1B?)&qPV?H&E3? zPX!iLlz5p~%4KZr#JB1*lafu(QDdTXvgoe@iWtbJ0YH450apFWKCswOjI zx|moSvu_)zH?c#vB+tVoIjC)2T`QjmHK_1x-Z3O`oG(pt{L6~$9;^h`Ga9tgiRIQ+ zIYC@2k2ivU{fn3|-}-rG{`E=rTtAwldekBBJD(@PaLkAzY-hXIriDX)YBr47O0S8~ zP5hXYIA58*1|Tpm)ME5_8X2d=E-}HZigDC71lncn>+{6G0jQgRlKnRP7(i}VbX5{{`yzxBo!yg{+R8&-^lkd}>87mjP{U*HaW(r1{X`!P&S;&p}g#?O&A-anX@W zU@7^mkI&@el8(jQ$$tK?0ZR0@yl7e7t_=z`Y=WuxjoUD(=R&sYY&YVT7-7V19h2Z; zb`U;$R4fkM4o{D+n)yugFLcLqvyP(*3!9|R3D|qqg0?$5fB;1lWO8lA`Dutjp&>T% z!5yPoW4;^JEGFg!XVj^UJb`~Ds(&F>jWTk}Y6|ra!N6$fmgiy}#}_I%mpt8ha$pTmoA@Q-1u>&`<*cTfOJk#ifV+sj`~FJ`vzNUnor z%N-5lb;$2a;2#zim%Galw5lzNw1_v?6?(zBk!x(@zpk0p=e@ccHF!r_Vg?lYU zrxdoV!=L{49$0}SUzYfX!;9uXi#A?tNI4vAN_)MXfi`BJ1@Ll`OEwO?7yU`R2cQRn zhtf}_G){>O)uh9C!%ZWImgw$>=R>?GfgT>YzB6%T%vDW4@)hV~glC_dPs7|fB!iuTo(x23T1|RZq8aZuS~$vn6dGrpZ&4 zS8e+c9^!12Ofmrg43_`y0<7$e%aLwKB`5h-m;)Ka@D_qL4M{O( zVWvZ4lyS#2D;ty2^}*?H=;pqPd=LG)WhY=vLMdxzE8lmLp#=P(9P!13HYWv7WdHLZ zdP5lZFAY*#*gdmIq~E>DRw-63AvfWf-*&?xrk|HV`=slzZ3(76R+gy#ySG^H$&%z~ zS@3IqBw^P*C-SK=5C!(s<|=>iyl5nZn7VA+?Rlovap<^SOtSgTEC+8<`s6g;GvW)6 zpfT_Y1b#J$`rv{a{L4^TS|meeZe#9`*cdy}8XZjv;zNHz@)_3z^ge#foee{^=?w|N zS(x>(OzFAavvzkaP4&EmVV^v7%Bq_){8N8s-=lDZl0xlyoEN7Tl-Mkb?L4eex#wV2BS=EE?H7Q$ zCuy5DUN(JfZY)^C&O75cR9=uIf`&%I84jFq$2d!`?{K-^^&HyoxikfS<3E=5^9y_M zo~+P1e3d5L@g|*raMB?i&!O*aP18N9Y)Y}ls)F=cVjni$B&rA_3>Z456((9-`E^xE zeU%T8Oh>H+E|;PK_DVl#YR7e<{3CwOjhc{@B-i1A-~fdzs5%QkT0|slxLw0bAl@e$ zekN^ulq+W&_3?QlD(H&j&AmXw#40kZAA3v6kRmAt=OtbY(;^B%#44w_;tZMNvoV4h zvkKn_L28a)Yp(Xi>Em0tltSUfiW?dj^U`jcCckaWW@=yicKExT9wEgyp%FU5!*qw- zYnSeog72v$z{Tf$UJP(Iv%2%vQ8zOx;d;z!~Wv`2hV@s8Hz%DFXr$bhVO}3h=QsX(Uba_2jw!2F3^MM z6*DeliMqbxgA?ME5d5|kMofO%n9-J!lnC&MA2IEsa82}}d0lK+9E)4Rf(m>7Z;-H$ z)%}_)7l&o|67s_`m7kNn2J}cYC8Pgzx5bA$y-&f<9$4jUd}LuEsQ%suYkQi31l1V@ z%I09Ve$jd{Jkc1{*jjyZHE=fZztv0KVLm#YYpVPf>Cp<-C~fmc499yU+|c&DP%b|2 zP&g)+`=8b2u2k@YIH6+PZ+QnltNKLhMax4>-4uL~eUR_Nq_tBMl{svF0=QAGwMVX)9t5lUDVfMYf7g(7D~f^ zEXeD_^xZ=M{eQ{gS#uz!wYln3@g}Plfd1*Q=c`jgHH3kDn6LAV%J+ysTd{9Ag_erK z-I7`_Gy7~{w{7>UHfGxMmigPRX3s-}J?k1{RO!`r{tA2rk8nY;s49x)`_0jM)5<$tntSz z%&Yo*OLOwU!oGbM&;Qmi(#K3y=%s1`9!n1rtQdjJis`fx)+ItbJ6DpIQ(h;fySI;u z9wADdMCZ?FfxsP*B2CD8!A*2N`~SRDI7RF8k&g_!<=sPfPxG3% zrSx3YLJz--5EQ3DT-49DMqD4861bz7+YH?j%KZH=%vM*I3UB*h@jwVOf5MdGqI1M4 zWc)~F+emx(0|@jQ48H?N6jc zqGl+{5S8dE?+9@)cvtDES{oF>HYT;xkdFVXtf)cW~U}?|$p(3{IcwCc1Dr)aedPL}3t>t8oD(VFr zDXd=rGy7pBVXW-?PgVVOYc?kgD7_q}3pTrx4G&j;`Be zyLysU_lG`fdmQjSfQdV7wR57H6Tk0$a`J!+vjMj?YK0Z!fZ*1-jC9mh(>b2Bh$u@~ zM9_O9lIAkKDc7H_%XX{`~Y zjty{rm?o`W(l1{5Ysa+k$TeE)W=m0P(!t_LO)0Yktny zK_c}khPQI`!)h?{$;10HUDjNw*H;9sJyz+m^{R3SAoD%+BleHytZbviUXQ6aaA7?X z10TK8OvsX_aY8u<%H$`>_skP`L#kgQ!FKhHz%6rS|BpdULt;wzwdbtaAAi}wSO>X? zhJjsaJjzSg0s77s&8rZ=Mgtk79s5b0C}qU;fI1~BDwt+Xu!^iHc#6qFN00t|lYoY$ z+A~ipcj6kx!Pe&Rx7F~S7LS3!Nni2N9Qj5=OCPn16+ok$!;AIiK-l8JPP;$#&hhha zb)(bx>@)q=3qDNZc;CC^Xz6UR`uo4a%m5(4=S@e7udsIb24ldO{Tr8tGHfo>R;}5G zV2OZAxtghrRB3xLz`fzC>X?mMz3L&(Ai2BKE5|lpIPB}(vk!8j7{-`UKHk_Wr?~rE z#vka76je@P1yEVH+94H*vEfRlzFy6ugBxVk>$Z&~JzDPfoQYy0_3g7UU*S&lVG8wm znQL9{qzeu7f&IERu~m#N4`|muNjaR6Pls88cOyqWOcaKKl%PI-|4;FtV8O=t%OuB;P$YV?v@p)9gNr z)hgGyZ5pzCSF+wXg!`?@pMMP*wu0Dh&6LY}=EhmoD5YWCRw;V_0qM}u+gV3B#r`x% zAMFXg<;S*`@kNgc!Gh)9*>`Rfn-Q#oQ9~Ws-82_%wv?%Md(zMdpuR6lh5vSx^XE($ zP5PP*4Uac;`u7sQM78`{CnMy - - - - - - - + {% if favicon %} + + + + + + {% endif %} + @@ -41,7 +42,13 @@
-

{{ settings.title }}

+

+ {% if logo %} + + {% else %} + {{ settings.title }} + {% endif %} +

diff --git a/themes/typemill/partials/layoutCover.twig b/themes/typemill/partials/layoutCover.twig index 0756b05..e0b8883 100644 --- a/themes/typemill/partials/layoutCover.twig +++ b/themes/typemill/partials/layoutCover.twig @@ -10,13 +10,14 @@ - - - - - - + {% if favicon %} + + + + + + {% endif %} diff --git a/themes/typemill/typemill.yaml b/themes/typemill/typemill.yaml index daeb2ae..829b7bf 100644 --- a/themes/typemill/typemill.yaml +++ b/themes/typemill/typemill.yaml @@ -1,5 +1,5 @@ name: Typemill Theme -version: 1.2.2 +version: 1.2.3 description: The standard theme for Typemill. Responsive, minimal and without any dependencies. It uses the system fonts Calibri and Helvetica. No JavaScript is used. author: Sebastian Schürmanns homepage: https://typemill.net @@ -27,6 +27,11 @@ forms: label: Different Design for Startpage checkboxlabel: Activate Special Startpage-Design + coverlogo: + type: checkbox + label: Logo on startpage + checkboxlabel: Show logo instead of title on startpage + start: type: text label: Label for Start Button