Просмотр исходного кода

Version 1.2.17: Format inline elements

trendschau 5 лет назад
Родитель
Сommit
7dccfdbfcb

+ 1 - 1
cache/lastCache.txt

@@ -1 +1 @@
-1571726903
+1572791939

+ 2 - 2
composer.json

@@ -1,9 +1,9 @@
 {
-    "name": "trendschau/typemill",
+    "name": "typemill/typemill",
     "type": "project",
     "description": "A crazy simple tool to create web-documentations and online manuals with markdown files.",
     "keywords": ["documentations","manuals","flat-file","Markdown","php"],
-    "homepage": "http://typemill.net",
+    "homepage": "https://typemill.net",
     "license": "MIT",
     "config": {
         "vendor-dir": "system/vendor"

+ 0 - 2
content/00-Welcome/03-Markdown-Test.md

@@ -299,8 +299,6 @@ $$
 x = \int_{0^1}^1(-b \pm \sqrt{b^2-4ac})/(2a)
 $$
 
-Das war es dann aber auch.
-
 [^1]: Thank you for scrolling.
 [^2]: This is the end of the page.
 

+ 13 - 9
readme.md

@@ -18,6 +18,7 @@ TYPEMILL is a small flat file cms created for editors and writers. It provides a
 * Allows super easy backend and frontend forms with simple YAML-files.
 * Ships with a fully responsive standard theme
 * Ships with plugins for 
+  * Search
   * MathJax and KaTeX.
   * Code highlighting.
   * Matomo/Piwik and Google Analytics.
@@ -60,7 +61,7 @@ You can use your ftp-software for that.
 
 ## Setup
 
-Please go to `your-typemill-website.com/setup`, create an initial user and then setup your system in the author panel. 
+Please go to `your-typemill-website.com/setup`, create an initial user and configure your system in the author panel. 
 
 ## Login
 
@@ -88,17 +89,20 @@ Typemill is still in an early stage and contributions are highly welcome. Here a
 Some ideas for devs (please fork this repository make your changes and create a pull request):
 
 * Fix a bug.
-* Create a nice theme.
-* Create a new plugin.
-* Improve the CSS-code with BEM or utility-css (e.g. Tailwind) and make it modular.
-* Rebuild the theme with the new css-grid feature.
+* Create a theme.
+* Create a plugin.
+* Auto-update functionality for core system plugins and themes.
+* Create a plugin and theme download page.
 * Improve the accessibility of html and css.
-* Help to establish autotests with selenium or cypress.
-* Write unit-tests.
-* Write an auto-update functionality.
+* Implement an ACL for user roles and rights.
 
 For hints, questions, problems and support, please open up a new issue on GitHub.
 
 ## Licence
 
-TYPEMILL is published under MIT licence. Please check the licence of the included libraries, too.
+TYPEMILL is published under MIT licence. Please check the licence of the included libraries, too.
+
+## Community & Supporters
+
+* [Eziquel Bruni](https://github.com/EzequielBruni) edits the typemill documentation.
+* [vodaris](https://www.vodaris.de) sponsored the development of the search plugin.

+ 15 - 2
system/Controllers/ContentApiController.php

@@ -87,7 +87,7 @@ class ContentApiController extends ContentController
 	}
 
 	public function unpublishArticle(Request $request, Response $response, $args)
-	{		
+	{
 		# get params from call 
 		$this->params 	= $request->getParams();
 		$this->uri 		= $request->getUri();
@@ -129,6 +129,19 @@ class ContentApiController extends ContentController
 			}
 		}
 		
+		# check if it is a folder and if the folder has published pages.
+		$message = false;
+		if($this->item->elementType == 'folder')
+		{
+			foreach($this->item->folderContent as $folderContent)
+			{
+				if($folderContent->status == 'published')
+				{
+					$message = 'There are published pages within this folder. The pages are not visible on your website anymore.';
+				}
+			}
+		}
+
 		# update the file
 		$delete = $this->deleteContentFiles(['md']);
 		
@@ -143,7 +156,7 @@ class ContentApiController extends ContentController
 			# dispatch event
 			$this->c->dispatcher->dispatch('onPageUnpublished', new OnPageUnpublished($this->item));
 			
-			return $response->withJson(['success'], 200);
+			return $response->withJson(['success' => ['message' => $message]], 200);
 		}
 		else
 		{

+ 9 - 1
system/Controllers/SettingsController.php

@@ -45,7 +45,7 @@ class SettingsController extends Controller
 			$params 		= $request->getParams();
 			$newSettings	= isset($params['settings']) ? $params['settings'] : false;
 			$validate		= new Validation();
-		
+
 			if($newSettings)
 			{
 				/* make sure only allowed fields are stored */
@@ -54,6 +54,7 @@ class SettingsController extends Controller
 					'author' 		=> $newSettings['author'],
 					'copyright' 	=> $newSettings['copyright'],
 					'year'			=> $newSettings['year'],
+					'language'		=> $newSettings['language'],
 					'startpage' 	=> isset($newSettings['startpage']) ? true : false,
 					'editor' 		=> $newSettings['editor'], 
 				);
@@ -562,6 +563,13 @@ class SettingsController extends Controller
 			if($validate->username($params['username']))
 			{
 				$user->deleteUser($params['username']);
+
+				# if user deleted his own account
+				if($_SESSION['user'] == $params['username'])
+				{
+					session_destroy();		
+					return $response->withRedirect($this->c->router->pathFor('auth.show'));
+				}
 				
 				$this->c->flash->addMessage('info', 'Say goodbye, the user is gone!');
 				return $response->withRedirect($this->c->router->pathFor('user.list'));			

+ 22 - 2
system/Controllers/SetupController.php

@@ -15,16 +15,36 @@ class SetupController extends Controller
 		$checkFolder = new Write();
 		
 		$systemcheck = array();
-		
+
+		# check folders and create them if possible		
 		try{ $checkFolder->checkPath('settings'); }catch(\Exception $e){ $systemcheck['error'][] = $e->getMessage(); }
 		try{ $checkFolder->checkPath('settings/users'); }catch(\Exception $e){ $systemcheck['error'][] = $e->getMessage(); }
 		try{ $checkFolder->checkPath('content'); }catch(\Exception $e){ $systemcheck['error'][] = $e->getMessage(); }
 		try{ $checkFolder->checkPath('cache'); }catch(\Exception $e){ $systemcheck['error'][] = $e->getMessage(); }
 		try{ $checkFolder->checkPath('media'); }catch(\Exception $e){ $systemcheck['error'][] = $e->getMessage(); }
 
+
+		# check php-version
+		if (version_compare(phpversion(), '7.0.0', '<')) {
+				$systemcheck['error'][] = 'The PHP-version of your server is ' . phpversion() . ' and Typemill needs at least 7.0.0';
+		}
+
+		# check if mod rewrite is enabled
+		$modules = apache_get_modules();
+		if(!in_array('mod_rewrite', $modules))
+		{
+			$systemcheck['error'][] = 'The apache module "mod_rewrite" is not enabled.';
+		}
+
+		# check if GD  extension is enabled
+		if(!extension_loaded('gd')){
+			$systemcheck['error'][] = 'The php-extension GD for image manipulation is not enabled.';
+		}
+
+		$setuperrors = empty($systemcheck) ? false : 'Some system requirements for Typemill are missing.';
 		$systemcheck = empty($systemcheck) ? false : $systemcheck;
 
-		return $this->render($response, 'auth/setup.twig', array( 'messages' => $systemcheck ));
+		return $this->render($response, 'auth/setup.twig', array( 'messages' => $setuperror, 'systemcheck' => $systemcheck ));
 	}
 
 	public function create($request, $response, $args)

+ 11 - 1
system/author/auth/setup.twig

@@ -4,9 +4,19 @@
 {% block content %}
 
 	<div class="setupWrapper">
+
+		{% if systemcheck %}
+			<h2>Missing Requirements</h2>
+			<ul style="color:red;padding: 0 14px">
+				{% for systemerror in systemcheck %}
+					<li style="margin: 5px 0">{{ systemerror }}</li>
+				{% endfor %}
+			</ul>
+		{% endif %}
+
 		<div class="authformWrapper">
 			<form method="POST" action="{{ path_for('setup.create') }}" autocomplete="off">
-			
+
 				<fieldset class="auth">
 					<div class="formElement{{ errors.username ? ' errors' : '' }}">
 						<label for="username">Username <abbr title="required">*</abbr></label>

+ 1 - 1
system/author/auth/welcome.twig

@@ -11,7 +11,7 @@
 				<h1>Hurra!</h1>
 				<p>Your account has been created and you are logged in now.</p>
 				<p><strong>Next step:</strong> Visit the author panel and setup your new website. You can configure the system, choose themes and add plugins.</p>
-				<p><strong>New:</strong> Typemill ships with a new search plugin now. Just activate the plugin and enjoy!!</p>
+				<p><strong>New:</strong>Not sure how to add strong, emphasis and inline-code with Markdown? We have buttons for that now!!</p>
 				<p><strong>Get help:</strong> If you have any questions, please consult the <a target="_blank" href="https://typemill.net/typemill"><i class="icon-link-ext"></i> docs</a> or open a new issue on <a target="_blank" href="https://github.com/typemill/typemill"><i class="icon-link-ext"></i> github</a>.</p>
 			</div>
 			<a class="button" href="{{ path_for('settings.show') }}">Configure your website</a>

+ 43 - 0
system/author/css/style.css

@@ -1660,6 +1660,49 @@ button.format-item.close:hover{
 	border: 1px solid #cc4146;
 }
 
+/************************
+** INLINE FORMATG BAR  **
+************************/
+
+/* format menu */
+.inlineFormatBar {  
+	height: 30px;  
+	padding: 5px 10px;  
+	background: #333;  
+	border-radius: 3px;  
+	position: absolute;  
+	top: 0;  
+	left: 0;  
+	transform: translate(-50%, -100%);  
+	transition: 0.2s all;  
+	display: flex;  
+	justify-content: center;  
+	align-items: center;
+}
+/* Triangle below format popup */
+.inlineFormatBar:after {  
+	content: '';  
+	position: absolute;  
+	left: 50%;  
+	bottom: -5px;  
+	transform: translateX(-50%);  
+	width: 0;  
+	height: 0;  
+	border-left: 6px solid transparent;  
+	border-right: 6px solid transparent;  
+	border-top: 6px solid #333;
+}
+.inlineFormatItem {  
+	color: #FFF;  
+	cursor: pointer;
+}
+.inlineFormatItem:hover {  
+	color: #1199ff;
+}
+.inlineFormatItem + .inlineFormatItem {  
+	margin-left: 10px;
+}
+
 /************************
 ** BLOX EDITOR CONTENT **
 ************************/

+ 146 - 24
system/author/js/vue-blox.js

@@ -78,7 +78,7 @@ const contentComponent = Vue.component('content-block', {
 			this.compmarkdown = $event;
 			this.$nextTick(function () {
 				this.$refs.preview.style.minHeight = this.$refs.component.offsetHeight + 'px';
-			});			
+			});
 		},
 		switchToEditMode: function()
 		{
@@ -86,7 +86,7 @@ const contentComponent = Vue.component('content-block', {
 			eventBus.$emit('closeComponents');
 			self = this;
 			self.$root.$data.freeze = true; 						/* freeze the data */
-		  self.$root.$data.sortdisabled = true;			/* disable sorting */
+		  	self.$root.$data.sortdisabled = true;			/* disable sorting */
 			this.preview = 'hidden'; 								/* hide the html-preview */
 			this.edit = true;										/* show the edit-mode */
 			this.compmarkdown = self.$root.$data.blockMarkdown;		/* get markdown data */
@@ -392,6 +392,123 @@ const contentComponent = Vue.component('content-block', {
 	},
 })
 
+const inlineFormatsComponent = Vue.component('inline-formats', {
+	template: '<div><div :style="{ left: `${x}px`, top: `${y}px` }" @mousedown.prevent="" v-show="showInlineFormat" id="formatBar" class="inlineFormatBar">' + 
+				  '<span class="inlineFormatItem" @mousedown.prevent="formatBold"><i class="icon-bold"></i></span>' + 
+				  '<span class="inlineFormatItem" @mousedown.prevent="formatItalic"><i class="icon-italic"></i></span>' + 
+				  '<span class="inlineFormatItem" @mousedown.prevent="formatCode"><i class="icon-code"></i></span>' + 
+				'</div><slot></slot></div>',
+	data: function(){
+		return {
+			formatBar: false,
+			startX: 0,
+			startY: 0,
+     		x: 0,
+     		y: 0,
+     		textComponent: '',
+     		selectedText: '',
+     		startPos: false,
+     		endPos: false,
+     		showInlineFormat: false,
+     	}
+	},
+	mounted: function() {
+		this.formatBar = document.getElementById('formatBar');
+		window.addEventListener('mouseup', this.onMouseup),
+		window.addEventListener('mousedown', this.onMousedown)
+	},
+	beforeDestroy: function() {
+		window.removeEventListener('mouseup', this.onMouseup),
+		window.removeEventListener('mousedown', this.onMousedown)
+	},
+	computed: {
+		highlightableEl () {    
+			return this.$slots.default[0].elm  
+		}
+	},
+	methods: {
+		onMousedown: function(event) {
+			this.startX = event.offsetX;
+			this.startY = event.offsetY;
+		},
+		onMouseup: function(event) {
+
+			/* if click is on format popup */
+			if(this.formatBar.contains(event.target))
+			{
+				return;
+			}
+
+			/* if click is outside the textarea */
+			if(!this.highlightableEl.contains(event.target))
+			{
+		  		this.showInlineFormat = false;
+		  		return;
+			}
+
+			this.textComponent = document.getElementsByClassName("mdcontent")[0];
+
+			/* grab the selected text */
+			if (document.selection != undefined)
+			{
+		    	this.textComponent.focus();
+		    	var sel = document.selection.createRange();
+		    	selectedText = sel.text;
+		  	}
+		  	/* Mozilla version */
+		  	else if (this.textComponent.selectionStart != undefined)
+		  	{
+		    	this.startPos = this.textComponent.selectionStart;
+		    	this.endPos = this.textComponent.selectionEnd;
+		    	selectedText = this.textComponent.value.substring(this.startPos, this.endPos)
+		  	}
+
+		  	var trimmedSelection = selectedText.replace(/\s/g, '');
+
+		  	if(trimmedSelection.length == 0)
+		  	{
+		  		this.showInlineFormat = false;
+		  		return;
+		  	}
+
+		  	/* determine the width of selection to position the format bar */
+		  	if(event.offsetX > this.startX)
+		  	{
+		  		var width = event.offsetX - this.startX;
+		  		this.x = event.offsetX - (width/2);
+		  	}
+		  	else
+		  	{
+		  		var width = this.startX - event.offsetX;
+		  		this.x = event.offsetX + (width/2);
+		  	}
+
+		  	this.y = event.offsetY - 15;
+
+			this.showInlineFormat = true;
+			this.selectedText = selectedText;
+		},
+		formatBold()
+		{
+			content = this.textComponent.value;
+			content = content.substring(0, this.startPos) + '**' + this.selectedText + '**' + content.substring(this.endPos, content.length);
+			this.$parent.updatemarkdown(content);
+		},
+		formatItalic()
+		{
+			content = this.textComponent.value;
+			content = content.substring(0, this.startPos) + '_' + this.selectedText + '_' + content.substring(this.endPos, content.length);
+			this.$parent.updatemarkdown(content);
+		},
+		formatCode()
+		{
+			content = this.textComponent.value;
+			content = content.substring(0, this.startPos) + '`' + this.selectedText + '`' + content.substring(this.endPos, content.length);
+			this.$parent.updatemarkdown(content);			
+		}
+	}
+})
+
 const titleComponent = Vue.component('title-component', {
 	props: ['compmarkdown', 'disabled'],
 	template: '<div><input type="text" ref="markdown" :value="compmarkdown" :disabled="disabled" @input="updatemarkdown"></div>',
@@ -411,16 +528,18 @@ const markdownComponent = Vue.component('markdown-component', {
 	props: ['compmarkdown', 'disabled'],
 	template: '<div>' + 
 				'<div class="contenttype"><i class="icon-paragraph"></i></div>' +
-				'<textarea class="mdcontent" ref="markdown" :value="compmarkdown" :disabled="disabled" @input="updatemarkdown"></textarea>' + 
-				'</div>',
+				'<inline-formats>' +
+					'<textarea id="activeEdit" class="mdcontent" ref="markdown" :value="compmarkdown" :disabled="disabled" @input="updatemarkdown(event.target.value)"></textarea>' + 
+			  	'</inline-formats>' +
+			  '</div>',
 	mounted: function(){
 		this.$refs.markdown.focus();
 		autosize(document.querySelectorAll('textarea'));
 	},
 	methods: {
-		updatemarkdown: function(event)
+		updatemarkdown: function(value)
 		{
-			this.$emit('updatedMarkdown', event.target.value);
+			this.$emit('updatedMarkdown', value);
 		},
 	},
 })
@@ -509,8 +628,10 @@ const quoteComponent = Vue.component('quote-component', {
 	template: '<div>' + 
 				'<input type="hidden" ref="markdown" :value="compmarkdown" :disabled="disabled" @input="updatemarkdown" />' +	
 				'<div class="contenttype"><i class="icon-quote-left"></i></div>' +
-				'<textarea class="mdcontent" ref="markdown" v-model="quote" :disabled="disabled" @input="createmarkdown"></textarea>' + 
-				'</div>',
+				'<inline-formats>' +
+					'<textarea class="mdcontent" ref="markdown" v-model="quote" :disabled="disabled" @input="updatemarkdown(event.target.value)"></textarea>' + 
+				'</inline-formats>' +
+			'</div>',
 	data: function(){
 		return {
 			quote: ''
@@ -521,7 +642,7 @@ const quoteComponent = Vue.component('quote-component', {
 		if(this.compmarkdown)
 		{
 			var quote = this.compmarkdown.replace("> ", "");
-			quote = this.compmarkdown.replace(">", "");
+			quote = this.compmarkdown.replace(">", "").trim();
 			this.quote = quote;
 		}
 		this.$nextTick(function () {
@@ -529,14 +650,10 @@ const quoteComponent = Vue.component('quote-component', {
 		});	
 	},
 	methods: {
-		createmarkdown: function(event)
-		{
-			this.quote = event.target.value;
-			var quote = '> ' + event.target.value;
-			this.updatemarkdown(quote);
-		},
-		updatemarkdown: function(quote)
+		updatemarkdown: function(value)
 		{
+			this.quote = value;
+			var quote = '> ' + value;
 			this.$emit('updatedMarkdown', quote);
 		},
 	},
@@ -546,7 +663,9 @@ const ulistComponent = Vue.component('ulist-component', {
 	props: ['compmarkdown', 'disabled'],
 	template: '<div>' + 
 				'<div class="contenttype"><i class="icon-list-bullet"></i></div>' +
-				'<textarea class="mdcontent" ref="markdown" v-model="compmarkdown" :disabled="disabled" @input="updatemarkdown"></textarea>' + 
+				'<inline-formats>' +
+					'<textarea class="mdcontent" ref="markdown" v-model="compmarkdown" :disabled="disabled" @input="updatemarkdown(event.target.value)"></textarea>' + 
+				'</inline-formats>' +
 				'</div>',
 	mounted: function(){
 		this.$refs.markdown.focus();
@@ -581,9 +700,9 @@ const ulistComponent = Vue.component('ulist-component', {
 		});	
 	},
 	methods: {
-		updatemarkdown: function(event)
+		updatemarkdown: function(value)
 		{
-			this.$emit('updatedMarkdown', event.target.value);
+			this.$emit('updatedMarkdown', value);
 		},
 	},
 })
@@ -592,7 +711,9 @@ const olistComponent = Vue.component('olist-component', {
 	props: ['compmarkdown', 'disabled'],
 	template: '<div>' + 
 				'<div class="contenttype"><i class="icon-list-numbered"></i></div>' +
-				'<textarea class="mdcontent" ref="markdown" v-model="compmarkdown" :disabled="disabled" @input="updatemarkdown"></textarea>' + 
+				'<inline-formats>' +
+					'<textarea class="mdcontent" ref="markdown" v-model="compmarkdown" :disabled="disabled" @input="updatemarkdown(event.target.value)"></textarea>' + 
+				'</inline-formats>' +
 				'</div>',
 	mounted: function(){
 		this.$refs.markdown.focus();
@@ -605,9 +726,9 @@ const olistComponent = Vue.component('olist-component', {
 		});
 	},
 	methods: {
-		updatemarkdown: function(event)
+		updatemarkdown: function(value)
 		{
-			this.$emit('updatedMarkdown', event.target.value);
+			this.$emit('updatedMarkdown', value);
 		},
 	},
 })
@@ -911,7 +1032,7 @@ const definitionComponent = Vue.component('definition-component', {
 	template: '<div class="definitionList">' +
 				'<div class="contenttype"><i class="icon-colon"></i></div>' +
 				'<draggable v-model="definitionList" :animation="150" @end="moveDefinition">' +
-  			  '<div class="definitionRow" v-for="(definition, dindex) in definitionList" :key="definition.id">' +
+  			    '<div class="definitionRow" v-for="(definition, dindex) in definitionList" :key="definition.id">' +
 						'<i class="icon-resize-vertical"></i>' +
 						'<input type="text" class="definitionTerm" placeholder="term" :value="definition.term" :disabled="disabled" @input="updateterm($event,dindex)" @blur="updateMarkdown">' +
 		  		  '<i class="icon-colon"></i>' + 
@@ -1337,6 +1458,7 @@ let editor = new Vue({
 		'table-component': tableComponent,
 		'definition-component': definitionComponent,
 		'math-component': mathComponent,
+		'inline-formats' : inlineFormatsComponent,
 	},
 	data: {
 		root: document.getElementById("main").dataset.url,
@@ -1580,7 +1702,7 @@ let editor = new Vue({
 						renderMathInElement(document.getElementById("blox-"+elementid));
 					});
 				}
-				if (typeof MathJax !== false) { 
+				if (typeof MathJax !== 'undefined') {
 					self.$nextTick(function () {
 						MathJax.Hub.Queue(["Typeset",MathJax.Hub,"blox-"+elementid]);
 					});

+ 8 - 8
system/author/js/vue-publishcontroller.js

@@ -203,24 +203,24 @@ let publishController = new Vue({
 					self.publishResult 		= "fail";
 					self.errors.message 	= "You are probably logged out. Please backup your changes, login and then try again."
 				}
-				else if(httpStatus != 200)
-				{
-					self.publishDisabled 	= false;
-					self.publishResult 		= "fail";
-					self.errors.message 	= "Something went wrong, please refresh the page and try again."					
-				}
 				else if(response)
 				{
 					var result = JSON.parse(response);
 					
+					self.modalWindow = false;
+
+					if(httpStatus != 200)
+					{
+						self.publishDisabled 	= false;
+						self.publishResult 		= "fail";
+						self.errors.message 	= "Something went wrong, please refresh the page and try again.";
+					}
 					if(result.errors)
 					{
-						self.modalWindow = "modal";
 						if(result.errors.message){ self.errors.message = result.errors.message };
 					}
 					else if(result.url)
 					{
-						self.modalWindow = "modal";
 						window.location.replace(result.url);
 					}
 				}

+ 4 - 2
system/author/settings/system.twig

@@ -3,6 +3,8 @@
 {% set startpage = old.settings.startpage ? old.settings.startpage : settings.startpage %}
 {% set linebreaks = old.settings.linebreaks ? old.settings.linebreaks : settings.linebreaks %}
 {% set year = settings.year ? settings.year : "now"|date("Y") %}
+{% set mylang = settings.language ? settings.language : locale %}
+{% set mycopy = settings.copyright ? settings.copyright : "@" %}
 
 {% block content %}
 	
@@ -36,7 +38,7 @@
 						<label for="settings[copyright]">Copyright/Licence</label>
 						<select name="settings[copyright]" id="copyright">
 							{% for copy in copyright %}
-								<option value="{{ copy }}"{% if copy == old.settings.copyright %} selected{% endif %}>{{ copy }}</option>
+								<option value="{{ copy }}"{% if copy == old.settings.copyright or copy == mycopy %} selected{% endif %}>{{ copy }}</option>
 							{% endfor %}
 						</select>
 						{% if errors.settings.copyright %}
@@ -52,7 +54,7 @@
 						<label for="settings[language]">Language</label>
 						<select name="settings[language]" id="language">
 							{% for key,lang in languages %}
-								<option value="{{ key }}"{% if (key == old.settings.language or key == locale) %} selected{% endif %}>{{ lang }}</option>
+								<option value="{{ key }}"{% if (key == old.settings.language or key == mylang) %} selected{% endif %}>{{ lang }}</option>
 							{% endfor %}
 						</select>
 						{% if errors.settings.language %}

+ 3 - 3
system/author/settings/themes.twig

@@ -14,7 +14,7 @@
 				
 				{% for themeName, theme in themes %}
 				
-					<form method="POST" action="{{ path_for('themes.save') }}">
+					<form method="POST" id="theme-{{ themeName }}" action="{{ path_for('themes.save') }}">
 					
 						<fieldset class="card{{ errors[themeName] ? ' errors' : '' }}">
 						
@@ -67,10 +67,10 @@
 									<input type="hidden" name="theme" value="{{ themeName }}">
 										
 									<div class="medium">
-										<button type="button" class="theme-button fc-settings{{ (settings.theme == themeName) ? ' active' : '' }}{{ theme.forms.fields|length > 0 ? ' has-settings' : ' no-settings'}}">{{ theme.forms.fields|length > 0 ? 'Settings <span class="button-arrow"></span>' : 'No Settings'}}</button>
+										<button type="button" id="{{themeName}}-toggle" class="theme-button fc-settings{{ (settings.theme == themeName) ? ' active' : '' }}{{ theme.forms.fields|length > 0 ? ' has-settings' : ' no-settings'}}">{{ theme.forms.fields|length > 0 ? 'Settings <span class="button-arrow"></span>' : 'No Settings'}}</button>
 									</div>
 									<div class="medium">
-										<input type="submit" value="Save Theme" />
+										<input type="submit" id="{{themeName}}-submit" value="Save Theme" />
 									</div>
 								</div>								
 							</div>