From c0e66d5f7012026e145d106f4d6bd3ba792a1b77 Mon Sep 17 00:00:00 2001 From: Yassine Doghri Date: Mon, 27 Jul 2020 09:35:34 +0000 Subject: [PATCH] feat: enhance ui using javascript in admin area - bundle js using parcel - add markdown editor, html editor, dropdown and tooltip features using third-party packages - integrate optimized inline svg icons from RemixIcon using svgo and a php helper - add scripts in package.json to bundle icons, images, css and js - update tailwind config to add purgecss lookups and typography plugin - refactor views to add missing pages in user journey - update admin's holy grail layout using css grid --- .devcontainer/devcontainer.json | 20 +- .eslintrc.json | 12 + .gitignore | 3 + .svgo.icons.yml | 9 + .svgo.yml | 8 + DEPENDENCIES.md | 20 +- app/Config/Routes.php | 11 +- app/Controllers/Admin/Episode.php | 7 + app/Controllers/Admin/Podcast.php | 48 +- app/Database/Seeds/AuthSeeder.php | 1 + app/Entities/Episode.php | 25 + app/Helpers/html_helper.php | 46 + app/Javascript/assets/styles/index.css | 3 - app/Language/en/AdminNavigation.php | 18 + app/Language/en/Podcast.php | 5 +- app/Models/PodcastModel.php | 4 + app/Views/_assets/admin.js | 11 + app/Views/_assets/icons/add.svg | 6 + app/Views/_assets/icons/caret-down.svg | 6 + app/Views/_assets/icons/dashboard.svg | 6 + app/Views/_assets/icons/edit.svg | 6 + app/Views/_assets/icons/external-link.svg | 6 + app/Views/_assets/icons/eye.svg | 6 + app/Views/_assets/icons/group.svg | 6 + app/Views/_assets/icons/mic.svg | 6 + app/Views/_assets/icons/more.svg | 6 + app/Views/_assets/images/logo-castopod.svg | 86 + app/Views/_assets/main.js | 1 + app/Views/_assets/modules/Dropdown.js | 56 + app/Views/_assets/modules/HTMLEditor.js | 19 + app/Views/_assets/modules/MarkdownEditor.js | 143 + app/Views/_assets/modules/Slugify.js | 32 + app/Views/_assets/modules/Tooltip.js | 61 + app/Views/_assets/styles/index.css | 2 + app/Views/_assets/styles/layout.css | 21 + app/Views/_assets/styles/tailwind.css | 9 + app/Views/_layout.php | 8 +- app/Views/admin/_header.php | 25 + app/Views/admin/_layout.php | 50 +- app/Views/admin/_partials/_episode-card.php | 43 + app/Views/admin/_partials/_episode-list.php | 11 + app/Views/admin/_partials/_podcast-card.php | 26 + app/Views/admin/_sidenav.php | 98 +- app/Views/admin/contributor/list.php | 12 +- app/Views/admin/dashboard.php | 6 +- app/Views/admin/episode/create.php | 8 +- app/Views/admin/episode/edit.php | 6 +- app/Views/admin/episode/list.php | 53 +- app/Views/admin/episode/view.php | 40 + app/Views/admin/podcast/create.php | 6 +- app/Views/admin/podcast/edit.php | 6 +- app/Views/admin/podcast/list.php | 44 +- app/Views/admin/podcast/view.php | 40 + app/Views/auth/_layout.php | 8 +- app/Views/episode.php | 8 + commitlint.config.js | 2 + composer.json | 3 +- composer.lock | 97 +- package-lock.json | 6738 ++++++++++++++++++- package.json | 33 +- postcss.config.js | 15 +- tailwind.config.js | 9 +- 62 files changed, 7873 insertions(+), 257 deletions(-) create mode 100644 .eslintrc.json create mode 100644 .svgo.icons.yml create mode 100644 .svgo.yml create mode 100644 app/Helpers/html_helper.php delete mode 100644 app/Javascript/assets/styles/index.css create mode 100644 app/Language/en/AdminNavigation.php create mode 100644 app/Views/_assets/admin.js create mode 100644 app/Views/_assets/icons/add.svg create mode 100644 app/Views/_assets/icons/caret-down.svg create mode 100644 app/Views/_assets/icons/dashboard.svg create mode 100644 app/Views/_assets/icons/edit.svg create mode 100644 app/Views/_assets/icons/external-link.svg create mode 100644 app/Views/_assets/icons/eye.svg create mode 100644 app/Views/_assets/icons/group.svg create mode 100644 app/Views/_assets/icons/mic.svg create mode 100644 app/Views/_assets/icons/more.svg create mode 100644 app/Views/_assets/images/logo-castopod.svg create mode 100644 app/Views/_assets/main.js create mode 100644 app/Views/_assets/modules/Dropdown.js create mode 100644 app/Views/_assets/modules/HTMLEditor.js create mode 100644 app/Views/_assets/modules/MarkdownEditor.js create mode 100644 app/Views/_assets/modules/Slugify.js create mode 100644 app/Views/_assets/modules/Tooltip.js create mode 100644 app/Views/_assets/styles/index.css create mode 100644 app/Views/_assets/styles/layout.css create mode 100644 app/Views/_assets/styles/tailwind.css create mode 100644 app/Views/admin/_header.php create mode 100644 app/Views/admin/_partials/_episode-card.php create mode 100644 app/Views/admin/_partials/_episode-list.php create mode 100644 app/Views/admin/_partials/_podcast-card.php create mode 100644 app/Views/admin/episode/view.php create mode 100644 app/Views/admin/podcast/view.php diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1a241bf6..d91b17e6 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -15,13 +15,15 @@ "color-highlight.markerType": "dot-before" }, "extensions": [ - "mikestead.dotenv", - "bmewburn.vscode-intelephense-client", - "streetsidesoftware.code-spell-checker", - "naumovs.color-highlight", - "heybourn.headwind", - "wayou.vscode-todo-highlight", - "esbenp.prettier-vscode", - "bradlc.vscode-tailwindcss" - ] + "mikestead.dotenv", + "bmewburn.vscode-intelephense-client", + "streetsidesoftware.code-spell-checker", + "naumovs.color-highlight", + "heybourn.headwind", + "wayou.vscode-todo-highlight", + "esbenp.prettier-vscode", + "bradlc.vscode-tailwindcss", + "jamesbirtles.svelte-vscode", + "dbaeumer.vscode-eslint" +] } diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..1ee6858f --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,12 @@ +{ + "env": { + "browser": true, + "es2020": true + }, + "extends": ["eslint:recommended", "plugin:prettier/recommended"], + "parserOptions": { + "ecmaVersion": 11, + "sourceType": "module" + }, + "rules": {} +} diff --git a/.gitignore b/.gitignore index 65ea29f0..d8ca8257 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,9 @@ nb-configuration.xml yarn.lock node_modules +# JS +.cache + # public folder public/* !public/.htaccess diff --git a/.svgo.icons.yml b/.svgo.icons.yml new file mode 100644 index 00000000..32c60fea --- /dev/null +++ b/.svgo.icons.yml @@ -0,0 +1,9 @@ +plugins: + - removeXMLNS: true + - removeDimensions: true + - addAttributesToSVGElement: + attributes: + - fill: currentColor + - width: "1em" + - height: "1em" + - sortAttrs: true diff --git a/.svgo.yml b/.svgo.yml new file mode 100644 index 00000000..562727d8 --- /dev/null +++ b/.svgo.yml @@ -0,0 +1,8 @@ +plugins: + - removeXMLNS: true + - removeDimensions: true + - addAttributesToSVGElement: + attributes: + - width: "1em" + - height: "1em" + - sortAttrs: true diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 77fc51eb..a7d57fe2 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -2,15 +2,23 @@ Castopod uses the following components: +PHP Dependencies: + - [Code Igniter 4](https://codeigniter.com) ([MIT License](https://codeigniter.com/user_guide/license.html)) -- [tailwindcss](https://tailwindcss.com/) ([MIT License](https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE)) -- [Tatter\Relations](https://github.com/tattersoftware/codeigniter4-relations) ([MIT License](https://github.com/tattersoftware/codeigniter4-relations/blob/develop/LICENSE)) -- [D3: Data-Driven Documents](https://github.com/d3/d3) ([BSD 3-Clause "New" or "Revised" License](https://github.com/d3/d3/blob/master/LICENSE)) -- [Rollup](https://github.com/rollup/rollup) ([MIT license](https://github.com/rollup/rollup/blob/master/LICENSE.md)) -- [Svelte](https://github.com/sveltejs/svelte) ([MIT license](https://github.com/sveltejs/svelte/blob/master/LICENSE)) - [User agent list](https://github.com/opawg/user-agents) ([by Open Podcast Analytics Working Group](https://github.com/opawg)) ([MIT license](https://github.com/opawg/user-agents/blob/master/LICENSE)) - [WhichBrowser/Parser-PHP](https://github.com/WhichBrowser/Parser-PHP) ([MIT License](https://github.com/WhichBrowser/Parser-PHP/blob/master/LICENSE)) - [GeoIP2 PHP API](https://github.com/maxmind/GeoIP2-php) ([Apache License 2.0](https://github.com/maxmind/GeoIP2-php/blob/master/LICENSE)) -- [Quill Rich Text Editor](https://github.com/quilljs/quill) ([BSD 3-Clause "New" or "Revised" License](https://github.com/quilljs/quill/blob/develop/LICENSE)) - [getID3](https://github.com/JamesHeinrich/getID3) ([GNU General Public License v3](https://github.com/JamesHeinrich/getID3/blob/2.0/licenses/license.gpl-30.txt)) - [myth-auth](https://github.com/lonnieezell/myth-auth) ([MIT license](https://github.com/lonnieezell/myth-auth/blob/develop/LICENSE.md)) +- [parsedown](https://github.com/erusev/parsedown) ([MIT license](https://github.com/erusev/parsedown/blob/master/LICENSE.txt)) + +Javascript dependencies: + +- [tailwindcss](https://tailwindcss.com/) ([MIT License](https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE)) +- [CodeMirror](https://github.com/codemirror/CodeMirror) ([MIT License](https://github.com/codemirror/CodeMirror/blob/master/LICENSE)) +- [ProseMirror](https://prosemirror.net/) ([MIT License](https://github.com/ProseMirror/prosemirror/blob/master/LICENSE)) +- [D3: Data-Driven Documents](https://github.com/d3/d3) ([BSD 3-Clause "New" or "Revised" License](https://github.com/d3/d3/blob/master/LICENSE)) + +Other: + +- [RemixIcon](https://remixicon.com/) ([Apache License 2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License)) diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 640d7250..552f33a6 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -61,12 +61,9 @@ $routes->group( ['namespace' => 'App\Controllers\Admin'], function ($routes) { $routes->get('/', 'Home', [ - 'as' => 'admin', + 'as' => 'admin_home', ]); - $routes->get('my-podcasts', 'Podcast::myPodcasts', [ - 'as' => 'my_podcasts', - ]); $routes->get('podcasts', 'Podcast::list', [ 'as' => 'podcast_list', 'filter' => 'permission:podcasts-list', @@ -81,6 +78,9 @@ $routes->group( // Use ids in admin area to help permission and group lookups $routes->group('podcasts/(:num)', function ($routes) { + $routes->get('/', 'Podcast::view/$1', [ + 'as' => 'podcast_view', + ]); $routes->get('edit', 'Podcast::edit/$1', [ 'as' => 'podcast_edit', ]); @@ -98,6 +98,9 @@ $routes->group( ]); $routes->post('new-episode', 'Episode::attemptCreate/$1'); + $routes->get('episodes/(:num)', 'Episode::view/$1/$2', [ + 'as' => 'episode_view', + ]); $routes->get('episodes/(:num)/edit', 'Episode::edit/$1/$2', [ 'as' => 'episode_edit', ]); diff --git a/app/Controllers/Admin/Episode.php b/app/Controllers/Admin/Episode.php index 2dc9aabc..70964117 100644 --- a/app/Controllers/Admin/Episode.php +++ b/app/Controllers/Admin/Episode.php @@ -77,6 +77,13 @@ class Episode extends BaseController return view('admin/episode/list', $data); } + public function view() + { + $data = ['episode' => $this->episode]; + + return view('admin/episode/view', $data); + } + public function create() { helper(['form']); diff --git a/app/Controllers/Admin/Podcast.php b/app/Controllers/Admin/Podcast.php index 3fa3110b..8259902f 100644 --- a/app/Controllers/Admin/Podcast.php +++ b/app/Controllers/Admin/Podcast.php @@ -18,6 +18,15 @@ class Podcast extends BaseController { if (count($params) > 0) { switch ($method) { + case 'view': + if ( + !has_permission('podcasts-view') || + !has_permission("podcasts:$params[0]-view") + ) { + throw new \RuntimeException( + lang('Auth.notEnoughPrivilege') + ); + } case 'edit': if ( !has_permission('podcasts-edit') || @@ -36,20 +45,6 @@ class Podcast extends BaseController lang('Auth.notEnoughPrivilege') ); } - case 'listContributors': - case 'addContributor': - case 'editContributor': - case 'deleteContributor': - if ( - !has_permission('podcasts-manage_contributors') || - !has_permission( - "podcasts:$params[0]-manage_contributors" - ) - ) { - throw new \RuntimeException( - lang('Auth.notEnoughPrivilege') - ); - } } $podcast_model = new PodcastModel(); @@ -61,24 +56,29 @@ class Podcast extends BaseController return $this->$method(); } - public function myPodcasts() - { - $data = [ - 'all_podcasts' => (new PodcastModel())->getUserPodcasts(user()->id), - ]; - - return view('admin/podcast/list', $data); - } - public function list() { $podcast_model = new PodcastModel(); - $data = ['all_podcasts' => $podcast_model->findAll()]; + $all_podcasts = []; + if (has_permission('podcasts-list')) { + $all_podcasts = $podcast_model->findAll(); + } else { + $all_podcasts = $podcast_model->getUserPodcasts(user()->id); + } + + $data = ['all_podcasts' => $all_podcasts]; return view('admin/podcast/list', $data); } + public function view() + { + $data = ['podcast' => $this->podcast]; + + return view('admin/podcast/view', $data); + } + public function create() { helper(['form', 'misc']); diff --git a/app/Database/Seeds/AuthSeeder.php b/app/Database/Seeds/AuthSeeder.php index cbbbe4f1..af0eeab0 100644 --- a/app/Database/Seeds/AuthSeeder.php +++ b/app/Database/Seeds/AuthSeeder.php @@ -65,6 +65,7 @@ class AuthSeeder extends Seeder 'name' => 'list', 'description' => 'List all podcasts and their episodes', ], + ['name' => 'view', 'description' => 'View any podcast'], ['name' => 'edit', 'description' => 'Edit any podcast'], [ 'name' => 'manage_contributors', diff --git a/app/Entities/Episode.php b/app/Entities/Episode.php index 20872716..6bc88127 100644 --- a/app/Entities/Episode.php +++ b/app/Entities/Episode.php @@ -9,6 +9,8 @@ namespace App\Entities; use App\Models\PodcastModel; use CodeIgniter\Entity; +use League\CommonMark\CommonMarkConverter; +use Parsedown; class Episode extends Entity { @@ -22,6 +24,7 @@ class Episode extends Entity protected string $enclosure_media_path; protected string $enclosure_url; protected array $enclosure_metadata; + protected string $description_html; protected $casts = [ 'slug' => 'string', @@ -153,4 +156,26 @@ class Episode extends Entity return $podcast_model->find($this->attributes['podcast_id']); } + + public function getDescriptionHtml() + { + $converter = new CommonMarkConverter([ + 'html_input' => 'strip', + 'allow_unsafe_links' => false, + 'renderer' => [ + 'soft_break' => '
', + ], + ]); + + if ( + $description_footer = $this->getPodcast() + ->episode_description_footer + ) { + return $converter->convertToHtml( + $this->attributes['description'] . '---' + ) . $converter->convertToHtml($description_footer); + } + + return $converter->convertToHtml($this->attributes['description']); + } } diff --git a/app/Helpers/html_helper.php b/app/Helpers/html_helper.php new file mode 100644 index 00000000..c3f8848f --- /dev/null +++ b/app/Helpers/html_helper.php @@ -0,0 +1,46 @@ + 'Dashboard', + 'podcasts' => 'Podcasts', + 'users' => 'Users', + 'admin_home' => 'Home', + 'podcast_list' => 'All podcasts', + 'podcast_create' => 'New podcast', + 'user_list' => 'All users', + 'user_create' => 'New user', + 'go_to_website' => 'Go to website' + ]; \ No newline at end of file diff --git a/app/Language/en/Podcast.php b/app/Language/en/Podcast.php index 54601135..2cad3845 100644 --- a/app/Language/en/Podcast.php +++ b/app/Language/en/Podcast.php @@ -11,8 +11,9 @@ return [ 'create' => 'Create a Podcast', 'new_episode' => 'New Episode', 'feed' => 'RSS feed', - 'edit' => 'Edit', - 'delete' => 'Delete', + 'view' => 'View podcast', + 'edit' => 'Edit podcast', + 'delete' => 'Delete podcast', 'see_episodes' => 'See episodes', 'see_contributors' => 'See contributors', 'goto_page' => 'Go to page', diff --git a/app/Models/PodcastModel.php b/app/Models/PodcastModel.php index 8d7365a9..86d47c3a 100644 --- a/app/Models/PodcastModel.php +++ b/app/Models/PodcastModel.php @@ -125,6 +125,10 @@ class PodcastModel extends Model $podcast_permissions = [ 'podcasts:' . $podcast->id => [ + [ + 'name' => 'View', + 'description' => "View the $podcast->name podcast", + ], [ 'name' => 'edit', 'description' => "Edit the $podcast->name podcast", diff --git a/app/Views/_assets/admin.js b/app/Views/_assets/admin.js new file mode 100644 index 00000000..2f390556 --- /dev/null +++ b/app/Views/_assets/admin.js @@ -0,0 +1,11 @@ +import Dropdown from "./modules/Dropdown"; +import HTMLEditor from "./modules/HTMLEditor"; +import MarkdownEditor from "./modules/MarkdownEditor"; +import Slugify from "./modules/Slugify"; +import Tooltip from "./modules/Tooltip"; + +Dropdown(); +Tooltip(); +MarkdownEditor(); +HTMLEditor(); +Slugify(); diff --git a/app/Views/_assets/icons/add.svg b/app/Views/_assets/icons/add.svg new file mode 100644 index 00000000..8f3f5a20 --- /dev/null +++ b/app/Views/_assets/icons/add.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/caret-down.svg b/app/Views/_assets/icons/caret-down.svg new file mode 100644 index 00000000..e2138c8d --- /dev/null +++ b/app/Views/_assets/icons/caret-down.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/dashboard.svg b/app/Views/_assets/icons/dashboard.svg new file mode 100644 index 00000000..1d2279e5 --- /dev/null +++ b/app/Views/_assets/icons/dashboard.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/edit.svg b/app/Views/_assets/icons/edit.svg new file mode 100644 index 00000000..ace6db3a --- /dev/null +++ b/app/Views/_assets/icons/edit.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/external-link.svg b/app/Views/_assets/icons/external-link.svg new file mode 100644 index 00000000..2a69c5f3 --- /dev/null +++ b/app/Views/_assets/icons/external-link.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/eye.svg b/app/Views/_assets/icons/eye.svg new file mode 100644 index 00000000..0b8b52e0 --- /dev/null +++ b/app/Views/_assets/icons/eye.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/group.svg b/app/Views/_assets/icons/group.svg new file mode 100644 index 00000000..fff52909 --- /dev/null +++ b/app/Views/_assets/icons/group.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/mic.svg b/app/Views/_assets/icons/mic.svg new file mode 100644 index 00000000..836c580d --- /dev/null +++ b/app/Views/_assets/icons/mic.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/icons/more.svg b/app/Views/_assets/icons/more.svg new file mode 100644 index 00000000..d77a746c --- /dev/null +++ b/app/Views/_assets/icons/more.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/Views/_assets/images/logo-castopod.svg b/app/Views/_assets/images/logo-castopod.svg new file mode 100644 index 00000000..191b6cc9 --- /dev/null +++ b/app/Views/_assets/images/logo-castopod.svg @@ -0,0 +1,86 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/app/Views/_assets/main.js b/app/Views/_assets/main.js new file mode 100644 index 00000000..6c2ee34f --- /dev/null +++ b/app/Views/_assets/main.js @@ -0,0 +1 @@ +console.log("main"); diff --git a/app/Views/_assets/modules/Dropdown.js b/app/Views/_assets/modules/Dropdown.js new file mode 100644 index 00000000..ef81f332 --- /dev/null +++ b/app/Views/_assets/modules/Dropdown.js @@ -0,0 +1,56 @@ +import { createPopper } from "@popperjs/core"; + +const Dropdown = () => { + const dropdownContainers = document.querySelectorAll( + "[data-toggle='dropdown']" + ); + + for (let i = 0; i < dropdownContainers.length; i++) { + const dropdownContainer = dropdownContainers[i]; + + const button = dropdownContainer.querySelector("[data-popper='button']"); + const menu = dropdownContainer.querySelector("[data-popper='menu']"); + + const popper = createPopper(button, menu, { + placement: menu.dataset.popperPlacement, + modifiers: [ + { + name: "offset", + options: { + offset: [menu.dataset.popperOffsetX, menu.dataset.popperOffsetY], + }, + }, + ], + }); + + const dropdownToggle = () => { + const isExpanded = !menu.classList.contains("hidden"); + + if (isExpanded) { + menu.classList.add("hidden"); + menu.classList.remove("flex"); + } else { + menu.classList.add("flex"); + menu.classList.remove("hidden"); + } + + button.setAttribute("aria-expanded", isExpanded); + popper.update(); + }; + + // Toggle dropdown menu on button click event + button.addEventListener("click", dropdownToggle); + + // Toggle off when clicking outside of dropdown + document.addEventListener("click", function (event) { + const isExpanded = !menu.classList.contains("hidden"); + const isClickOutside = !dropdownContainer.contains(event.target); + + if (isExpanded && isClickOutside) { + dropdownToggle(); + } + }); + } +}; + +export default Dropdown; diff --git a/app/Views/_assets/modules/HTMLEditor.js b/app/Views/_assets/modules/HTMLEditor.js new file mode 100644 index 00000000..c8132835 --- /dev/null +++ b/app/Views/_assets/modules/HTMLEditor.js @@ -0,0 +1,19 @@ +import CodeMirror from "codemirror"; +import "codemirror/lib/codemirror.css"; + +const HTMLEditor = () => { + const allHTMLEditors = document.querySelectorAll( + "textarea[data-editor='html']" + ); + + for (let j = 0; j < allHTMLEditors.length; j++) { + const textarea = allHTMLEditors[j]; + + CodeMirror.fromTextArea(textarea, { + lineNumbers: true, + mode: { name: "xml", htmlMode: true }, + }); + } +}; + +export default HTMLEditor; diff --git a/app/Views/_assets/modules/MarkdownEditor.js b/app/Views/_assets/modules/MarkdownEditor.js new file mode 100644 index 00000000..67f0c87b --- /dev/null +++ b/app/Views/_assets/modules/MarkdownEditor.js @@ -0,0 +1,143 @@ +import { exampleSetup } from "prosemirror-example-setup"; +import "prosemirror-example-setup/style/style.css"; +import { + defaultMarkdownParser, + defaultMarkdownSerializer, + schema, +} from "prosemirror-markdown"; +import "prosemirror-menu/style/menu.css"; +import { EditorState } from "prosemirror-state"; +import { EditorView } from "prosemirror-view"; +import "prosemirror-view/style/prosemirror.css"; + +class MarkdownView { + constructor(target) { + this.textarea = target; + this.textarea.classList.add("w-full", "h-full"); + } + + get content() { + return this.textarea.innerHTML; + } + focus() { + this.textarea.focus(); + } + show() { + this.textarea.classList.remove("hidden"); + } + hide() { + this.textarea.classList.add("hidden"); + } +} + +class ProseMirrorView { + constructor(target, content) { + this.editorContainer = document.createElement("div"); + this.editorContainer.classList.add( + "bg-white", + "border", + "px-2", + "min-h-full" + ); + this.editorContainer.style.minHeight = "200px"; + const editor = target.parentNode.insertBefore( + this.editorContainer, + target.nextSibling + ); + + this.view = new EditorView(editor, { + state: EditorState.create({ + doc: defaultMarkdownParser.parse(content), + plugins: exampleSetup({ schema }), + }), + dispatchTransaction: (transaction) => { + let newState = this.view.state.apply(transaction); + this.view.updateState(newState); + + if (transaction.docChanged) { + target.innerHTML = this.content; + } + }, + }); + } + + get content() { + return defaultMarkdownSerializer.serialize(this.view.state.doc); + } + focus() { + this.view.focus(); + } + show() { + this.editorContainer.classList.remove("hidden"); + } + hide() { + this.editorContainer.classList.add("hidden"); + } +} + +const MarkdownEditor = () => { + const targets = document.querySelectorAll("textarea[data-editor='markdown']"); + const activeClass = ["font-bold"]; + + for (let i = 0; i < targets.length; i++) { + const target = targets[i]; + + const wysiwygBtn = document.createElement("button"); + wysiwygBtn.classList.add( + ...activeClass, + "py-1", + "px-2", + "bg-white", + "border", + "text-xs" + ); + wysiwygBtn.setAttribute("type", "button"); + wysiwygBtn.innerHTML = "Wysiwyg"; + const markdownBtn = document.createElement("button"); + markdownBtn.classList.add("py-1", "px-2", "bg-white", "border", "text-xs"); + markdownBtn.setAttribute("type", "button"); + markdownBtn.innerHTML = "Markdown"; + + const viewButtons = document.createElement("div"); + viewButtons.appendChild(wysiwygBtn); + viewButtons.appendChild(markdownBtn); + viewButtons.classList.add( + "inline-flex", + "absolute", + "top-0", + "right-0", + "-mt-6" + ); + + const markdownEditorContainer = document.createElement("div"); + markdownEditorContainer.classList.add("relative"); + markdownEditorContainer.style.minHeight = "200px"; + target.parentNode.appendChild(markdownEditorContainer); + markdownEditorContainer.appendChild(target); + + // show WYSIWYG editor by default + target.classList.add("hidden"); + const markdownView = new MarkdownView(target); + const wysiwygView = new ProseMirrorView(target, markdownView.content); + + markdownEditorContainer.appendChild(viewButtons); + + markdownBtn.addEventListener("click", () => { + if (markdownBtn.classList.contains(...activeClass)) return; + markdownBtn.classList.add(...activeClass); + wysiwygBtn.classList.remove(...activeClass); + wysiwygView.hide(); + markdownView.show(); + }); + + wysiwygBtn.addEventListener("click", () => { + if (wysiwygBtn.classList.contains(...activeClass)) return; + wysiwygBtn.classList.add(...activeClass); + markdownBtn.classList.remove(...activeClass); + markdownView.hide(); + wysiwygView.show(); + }); + } +}; + +export default MarkdownEditor; diff --git a/app/Views/_assets/modules/Slugify.js b/app/Views/_assets/modules/Slugify.js new file mode 100644 index 00000000..238295b4 --- /dev/null +++ b/app/Views/_assets/modules/Slugify.js @@ -0,0 +1,32 @@ +// Original code from: https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1 +const slugify = (string) => { + const a = + "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;"; + const b = + "aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------"; + const p = new RegExp(a.split("").join("|"), "g"); + + return string + .toString() + .toLowerCase() + .replace(/\s+/g, "-") // Replace spaces with - + .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters + .replace(/&/g, "-and-") // Replace & with 'and' + .replace(/[^\w-]+/g, "") // Remove all non-word characters + .replace(/--+/g, "-") // Replace multiple - with single - + .replace(/^-+/, "") // Trim - from start of text + .replace(/-+$/, ""); // Trim - from end of text +}; + +const Slugify = () => { + const title = document.querySelector("input[data-slugify='title']"); + const slug = document.querySelector("input[data-slugify='slug']"); + + if (title && slug) { + title.addEventListener("input", () => { + slug.value = slugify(title.value); + }); + } +}; + +export default Slugify; diff --git a/app/Views/_assets/modules/Tooltip.js b/app/Views/_assets/modules/Tooltip.js new file mode 100644 index 00000000..153bc645 --- /dev/null +++ b/app/Views/_assets/modules/Tooltip.js @@ -0,0 +1,61 @@ +import { createPopper } from "@popperjs/core"; + +const Tooltip = () => { + const tooltipContainers = document.querySelectorAll( + "[data-toggle='tooltip']" + ); + + for (let i = 0; i < tooltipContainers.length; i++) { + const tooltipReference = tooltipContainers[i]; + const tooltipContent = tooltipReference.title; + + const tooltip = document.createElement("div"); + tooltip.setAttribute("id", "tooltip"); + tooltip.setAttribute( + "class", + "px-2 py-1 text-sm bg-gray-900 text-white rounded" + ); + tooltip.innerHTML = tooltipContent; + + const popper = createPopper(tooltipReference, tooltip, { + placement: tooltipReference.dataset.placement, + modifiers: [ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + ], + }); + + const show = () => { + tooltipReference.removeAttribute("title"); + tooltipReference.setAttribute("aria-describedby", "tooltip"); + document.body.appendChild(tooltip); + popper.update(); + }; + + const hide = () => { + const element = document.getElementById("tooltip"); + tooltipReference.removeAttribute("aria-describedby"); + tooltipReference.setAttribute("title", tooltipContent); + if (element) { + document.body.removeChild(element); + } + }; + + const showEvents = ["mouseenter", "focus"]; + const hideEvents = ["mouseleave", "blur"]; + + showEvents.forEach((event) => { + tooltipReference.addEventListener(event, show); + }); + + hideEvents.forEach((event) => { + tooltipReference.addEventListener(event, hide); + }); + } +}; + +export default Tooltip; diff --git a/app/Views/_assets/styles/index.css b/app/Views/_assets/styles/index.css new file mode 100644 index 00000000..5f4c3b7f --- /dev/null +++ b/app/Views/_assets/styles/index.css @@ -0,0 +1,2 @@ +@import "./tailwind.css"; +@import "./layout.css"; diff --git a/app/Views/_assets/styles/layout.css b/app/Views/_assets/styles/layout.css new file mode 100644 index 00000000..bed5c1eb --- /dev/null +++ b/app/Views/_assets/styles/layout.css @@ -0,0 +1,21 @@ +.holy-grail-grid { + @apply grid; + grid-template: auto 1fr auto / auto 1fr auto; + + & .holy-grail-header { + grid-column: 1 / 4; + } + + & .holy-grail-sidenav { + grid-column: 1 / 2; + grid-row: 2 / 4; + } + + & .holy-grail-main { + grid-column: 2 / 4; + } + + & .holy-grail-footer { + grid-column: 2 / 4; + } +} diff --git a/app/Views/_assets/styles/tailwind.css b/app/Views/_assets/styles/tailwind.css new file mode 100644 index 00000000..efba14d0 --- /dev/null +++ b/app/Views/_assets/styles/tailwind.css @@ -0,0 +1,9 @@ +@tailwind base; + +/* Start purging... */ +@tailwind components; +/* Stop purging. */ + +/* Start purging... */ +@tailwind utilities; +/* Stop purging. */ diff --git a/app/Views/_layout.php b/app/Views/_layout.php index 92d581ba..ce60974f 100644 --- a/app/Views/_layout.php +++ b/app/Views/_layout.php @@ -2,12 +2,12 @@ - + Castopod - - + + - + diff --git a/app/Views/admin/_header.php b/app/Views/admin/_header.php new file mode 100644 index 00000000..3fc1d877 --- /dev/null +++ b/app/Views/admin/_header.php @@ -0,0 +1,25 @@ +
+ + + Admin + +
+ + +
+
\ No newline at end of file diff --git a/app/Views/admin/_layout.php b/app/Views/admin/_layout.php index 711a7ae7..a2d2a763 100644 --- a/app/Views/admin/_layout.php +++ b/app/Views/admin/_layout.php @@ -1,39 +1,33 @@ + + - + Castopod Admin - - + + - + + - -
-
- Castopod Admin - Go to website - -
-
-
- -
-

renderSection('title') ?>

- - renderSection('content') ?> -
-
-