diff --git a/parameciofast/fast.py b/parameciofast/fast.py
index 1a6323e..32b03be 100644
--- a/parameciofast/fast.py
+++ b/parameciofast/fast.py
@@ -28,6 +28,8 @@ for name_module, module_app in config.apps.items():
base_path=controller_mod.__file__.replace('__init__.py', '')
+ app_basedir=os.path.basename(base_path[0:len(base_path)-1])
+
media_path=base_path+'media/'
dir_controllers=os.listdir(base_path)
@@ -39,9 +41,10 @@ for name_module, module_app in config.apps.items():
subcontroller_path=module+'.'+controller.replace('.py', '')
subcontroller_mod=import_module(subcontroller_path)
- if os.path.isfile(media_path):
+ if os.path.isdir(media_path):
if yes_static:
- app.mount("/mediafrom/"+name_app, StaticFiles(directory=media_path), name="static_"+name_app)
+
+ app.mount("/mediafrom/"+app_basedir, StaticFiles(directory=media_path), name="static_"+app_basedir)
fast_app=getattr(controller_mod, app_module)
diff --git a/parameciofast/libraries/config_admin.py b/parameciofast/libraries/config_admin.py
new file mode 100644
index 0000000..a688564
--- /dev/null
+++ b/parameciofast/libraries/config_admin.py
@@ -0,0 +1,27 @@
+"""
+Paramecio2fm is a series of wrappers for Flask, mako and others and construct a simple headless cms.
+
+Copyright (C) 2023 Antonio de la Rosa Caballero
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+"""
+
+# Variable base for admin modules
+
+"""Variables for control admin modules for admin module
+"""
+
+config_admin=[]
+"""list: List for save the differente admin configurations from paramecio2 modules
+"""
diff --git a/parameciofast/libraries/i18n.py b/parameciofast/libraries/i18n.py
new file mode 100644
index 0000000..52aaf8d
--- /dev/null
+++ b/parameciofast/libraries/i18n.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+
+"""
+Parameciofast is a series of wrappers for Fastapi, mako and others and construct a simple headless cms.
+
+Copyright (C) 2023 Antonio de la Rosa Caballero
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+"""
+
+from importlib import import_module
+import gettext
+#from paramecio.citoplasma.sessions import get_session
+import json
+#from flask import session, has_request_context
+import os
+#from fastapi import Request
+
+yes_session=False
+
+i18n_module={}
+
+def load_lang(*args):
+ """A function for load the lang module dinamically
+ """
+
+ for module in args:
+
+ lang_path=module[0]+'.i18n.'+module[1]
+
+ try:
+
+ i18n_module[lang_path]=import_module(lang_path)
+
+ pass
+
+ except:
+ pass
+
+ # here load the language
+
+class PGetText:
+
+ # Dict where all gettext domain are saved -> domain=name, example, admin, libraries, pastafari2, etc...
+
+ l={}
+
+ def __init__(self, module_file):
+
+ module_dir=os.path.dirname(os.path.realpath(module_file))
+
+ module_name=os.path.basename(module_dir)
+
+ if module_name not in PGetText.l:
+
+ PGetText.l[module_name]={}
+
+ for i in I18n.dict_i18n:
+
+ if i not in PGetText.l[module_name]:
+
+ PGetText.l[module_name][i]=gettext.translation(module_name, module_dir+'/languages/', languages=[i], fallback=True)
+ PGetText.l[module_name][i].install()
+
+ self.module=module_name
+
+ def gettext(self, text):
+
+ return PGetText.l[self.module][I18n.get_default_lang()].gettext(text)
+
+def pgettext(module_file):
+
+ module=os.path.dirname(os.path.realpath(module_file))
+
+ base_name=os.path.dirname(os.path.realpath(module))
+
+ l=gettext.translation(os.path.basename(base_name), module+'/languages/', languages=I18n.get_default_lang(), fallback=True)
+
+ return l.gettext
+
+class I18n:
+ """Class for i18n tasks
+
+ Class for i18n tasks, how, strings for every language supported, for now are en-US and es-ES. You can add more languages adding
+
+ Attributes:
+ default_lang (str): The default string lang used when get someone
+ dict_i18n (list): The list with default languages. You can add more calling it static variable in settings/config.py
+
+ """
+
+ default_lang='en-US'
+
+ dict_i18n=['en-US', 'es-ES']
+
+ l={}
+
+ def __init__(self, module, default_lang=None):
+
+ self.module=module
+ self.default_lang=I18n.default_lang
+
+ def slang(self, symbol, text_default, lang=None):
+ """Method for get a string from selected language but object oriented
+
+ Method for get a string from selected language but object oriented
+
+ Args:
+ symbol (str): The symbol used for identify the text string.
+ text_default (str): The text default used. You have use how base for translations.
+ """
+ return I18n.lang(self.module, symbol, text_default, lang)
+
+ def tlang(self, text_default, lang=None):
+ """Method for get a string from selected language but object oriented and using module and symbol by default
+
+ Method for get a string from selected language but object oriented and using module and symbol by default
+
+ Args:
+ symbol (str): The symbol used for identify the text string.
+ text_default (str): The text default used. You have use how base for translations.
+ """
+
+ symbol=text_default[:60]
+
+ return I18n.lang(self.module, symbol, text_default)
+
+ #@staticmethod
+ #def set_lang(code_lang):
+ # if default_lang
+
+
+ @staticmethod
+ def get_default_lang():
+ """Static method for get the default lang"""
+
+ lang=I18n.default_lang
+
+ #if has_request_context():
+ #lang=session.get('lang', lang)
+
+ return lang
+
+ @staticmethod
+ def lang(module, symbol, text_default):
+ """Static method for get a string from selected language
+
+ Static method used to get the string of the selected language. If there is no string in the selected language, it returns text_default.
+
+ Args:
+ module (str): The module to which the translation string belongs
+ symbol (str): Simple symbol that is useful for identify the string
+ text_default (str): The text used by default when there are not translation in the selected language
+ """
+
+ #if not lang:
+ # lang=I18n.get_default_lang()
+
+ lang=I18n.get_default_lang()
+
+ I18n.l[lang]=I18n.l.get(lang, {})
+
+ I18n.l[lang][module]=I18n.l[lang].get(module, {})
+
+ I18n.l[lang][module][symbol]=I18n.l[lang][module].get(symbol, text_default)
+
+ return I18n.l[lang][module][symbol]
+
+ @staticmethod
+ def extract_value(value):
+ """Static method for get values from json lang array
+
+ Args:
+ value (json): Lang dict in json format
+ """
+
+ value=json.loads(value)
+
+ lang=I18n.get_default_lang()
+
+ if value[lang]!='':
+
+ return value[lang]
+
+ return value[I18n.default_lang]
+
+ """
+ @staticmethod
+ def get_browser_lang():
+
+ return request.headers.get('Accept-Language', 'en-US')
+ """
+ @staticmethod
+ def lang_json(module, symbol, text_default):
+ """Static method for return a language dict in JSON
+
+ Static method used to get the string of the selected language in JSON format. If there are not string in the selected language, it returns text_default.
+
+ Args:
+ module (str): The module to which the translation string belongs
+ symbol (str): Simple symbol that is useful for identify the string
+ text_default (str): The text used by default when there are not translation in the selected language
+ """
+
+ arr_final={}
+
+ for l in I18n.dict_i18n:
+ arr_final[l]=I18n.lang(module, symbol, text_default, l)
+
+ return json.dumps(arr_final)
+
+ @staticmethod
+ def session_lang(request):
+
+ return request.session.get('lang', I18n.get_default_lang())
+
+common_pgettext=PGetText(__file__)
diff --git a/parameciofast/libraries/mtemplates.py b/parameciofast/libraries/mtemplates.py
new file mode 100644
index 0000000..5c17342
--- /dev/null
+++ b/parameciofast/libraries/mtemplates.py
@@ -0,0 +1,287 @@
+"""
+Paramecio2fm is a series of wrappers for Fastapi, mako and others and construct a simple headless cms.
+
+Copyright (C) 2024 Antonio de la Rosa Caballero
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+"""
+
+# Template frontend from mako.
+
+#import gettext
+from mako.template import Template
+#from flask import url_for as url_for_flask
+from mako.lookup import TemplateLookup
+from os import path
+try:
+ from settings import config
+except:
+ class config:
+ theme='default'
+ reloader=False
+
+#import gettext
+import sys
+from parameciofast.libraries.i18n import I18n, PGetText
+from parameciofast.libraries.urls import make_url, make_media_url, add_get_parameters
+#from parameciofast.libraries.formsutils import csrf_token
+
+framework='flask'
+
+if hasattr(config, 'framework'):
+ framework=config.framework
+
+
+"""
+def _(text):
+
+ return gettext.gettext(text)
+"""
+
+def env_theme(module, cache_enabled=True, cache_impl='', cache_args={}, module_directory="./tmp/modules"):
+ """Function for create an environment for mako templates
+
+ Function for create an environment for mako templates. Really is a shortcut for TemplateLookup mako function. You can define cache options and module_directory for templates compiled
+
+ Args:
+ module (str): The module where the templates can be founded
+ cache_enabled (boolean): If True then mako template cache is enabled, is False, mako template cache is disabled.
+ cache_args (dict): Cache Args dict parameter for TemplateLookup function from Mako templates. View Mako Templates documentation.
+ module_directory (str): Module directory parameter for TemplateLookup function from Mako templates. View Mako Templates documentation.
+
+ Returns:
+
+ template (TemplateLookup): Return TemplateLookup object
+
+ """
+
+ ext=module[len(module)-3:]
+
+ if ext=='.py':
+
+ module=path.dirname(module)
+
+ standard_templates=path.dirname(__file__)+'/templates'
+
+ standard_languages=path.dirname(__file__)+'/languages'
+
+ module_directory+='/'+module
+
+ module_templates=module+'/templates'
+
+ theme_templates='themes/'+config.theme+'/templates'
+
+ search_folders=[theme_templates, module_templates, standard_templates]
+
+ #if self.inject_folder is not None:
+ #search_folders.insert(1, self.inject_folder+'/templates')
+
+ #Standard templates
+ #print(standard_templates)
+ return TemplateLookup(directories=search_folders, default_filters=['h'], input_encoding='utf-8', encoding_errors='replace', cache_enabled=cache_enabled, cache_impl=cache_impl, cache_args=cache_args, module_directory=module_directory, filesystem_checks=config.reloader)
+
+class PTemplate:
+ """A class used how shortcuts for Mako template functions.
+ """
+
+ templates_loaded={}
+
+ def __init__(self, environment, url_for):
+ """A class used how shortcuts for Mako template functions.
+
+ This class is used to have a set of shortcuts and hooks to Mako templates functions and methods over a series of default options.
+
+ Args:
+ environment (TemplateLookup): A TemplateLookup object generated with env_theme function
+
+ Attributes:
+ autoescape_ext (set): A set of extensions file where automatic autoescape is used
+ environment (TemplateLookup): A TemplateLookup object generated with env_theme function
+ filters (list): A list of functions used for add filters to your templates.
+ js (list): A list of javascript sources for generate js html load tags.
+
+ """
+
+ self.autoescape_ext=('html', 'htm', 'xml', 'phtml', 'js')
+
+ self.env=environment
+
+ self.filters={}
+
+ self.js={}
+
+ base_name=path.dirname(path.realpath(__file__))
+
+ #self.i18n=I18n(base_name, default_lang)
+
+ #self.add_filter(self.i18n.lang)
+
+ #self.add_filter(make_url)
+
+ self.add_filter(make_media_url)
+ """
+ if not url_for_function:
+
+ url_for=url_for_flask
+
+ self.add_filter(url_for)
+
+ else:
+
+ url_for=url_for_function
+
+ self.add_filter(url_for)
+
+ """
+
+ self.add_filter(url_for)
+
+ #self.add_filter(csrf_token)
+
+ #self.add_filter(add_get_parameters)
+
+ self.add_filter(self.add_js)
+
+ self.add_filter(self.load_js)
+
+ # Loading language domain for gettext in templates
+
+ module_env=self.env.directories[1].replace('/templates', '')
+
+ #print(path.basename(module_env)+' '+base_name+'/languages/')
+
+ self.l=PGetText(module_env+'/app.py')
+
+ self.add_filter(self._)
+
+ #self.add_filter(self.i18n.slang)
+
+ #self.add_filter(self.i18n.tlang)
+
+ def _(self, text):
+
+ return self.l.gettext(text)
+
+ def add_js(self, js, module=''):
+ """Function for add js to self.js attribute
+
+ Add a js file name to an attribute list called self.js with the '.format(make_media_url('js/'+js, module))
+
+ return ""
+
+ def load_js(self):
+
+ return "\n".join(self.js.values())
+
+
+ """
+ def gettext(self, text):
+ return gettext.dgettext(self.domain_gettext, text)
+ """
+
+ def load_template(self, template_file, **arguments):
+ """Load a mako template and return the result
+
+ Load a mako template and return the results with different arguments applied
+
+ Args:
+ template_file (str): The name of template file. The template is searched using configuration defined in self.env
+ **arguments (mixed): Extra arguments with variables passed to template
+
+ Returns:
+ template (str): Return a template rendered using mako class from self.env
+ """
+
+ """
+ if self.prepare_gettext:
+
+ module_lang_dir=self.env.directories[1].replace('/templates', '/locales')
+
+ module_name=path.basename(module_lang_dir.replace('/locales', ''))
+
+ en = gettext.translation(module_name, localedir=module_lang_dir, languages=['en'])
+ en.install()
+ _=en.gettext
+
+ arguments.update({'_': _})
+ """
+ template = self.env.get_template(template_file)
+
+ arguments.update(self.filters)
+
+ return template.render(**arguments)
+
+ def guess_autoescape(self, template_name):
+ """Simple helper method for get an extension from filename
+
+ Args:
+ template_name (str): The template name
+ """
+
+ if template_name is None or '.' not in template_name:
+ return False
+
+ ext = template_name.rsplit('.', 1)[1]
+
+ return ext in self.autoescape_ext
+
+ def render_template(self, template_file, **arguments):
+ """Experimental method for parse a template
+
+ Experimental method for parse a template, similar to load_template but try cache the template loaded
+
+ Args:
+ template_file (str): The name of template file. The template is searched using configuration defined in self.env
+ **arguments (mixed): Extra arguments with variables passed to template
+
+ Returns:
+ dummy (str): Dummy return necessary because mako expect return something
+ """
+
+ if not str(self.env.directories)+'/'+template_file in PTemplate.templates_loaded:
+ PTemplate.templates_loaded[str(self.env.directories)+'/'+template_file]=self.env.get_template(template_file)
+
+ arguments.update(self.filters)
+
+ return PTemplate.templates_loaded[str(self.env.directories)+'/'+template_file].render(**arguments)
+
+ def add_filter(self, filter_name):
+ """Method for add filters to self.filters attributes
+
+ Method for add filters to self.filters attributes for use in templates
+
+ Args:
+ filter_name (function): Filter function
+ """
+
+ self.filters[filter_name.__name__]=filter_name
+
+def add_css_home_local(file_css, module):
+
+ pass
+
+#env=env_theme(__file__)
+
+#standard_t=PTemplate(env)
+
diff --git a/parameciofast/libraries/session.py b/parameciofast/libraries/session.py
new file mode 100644
index 0000000..4fd6411
--- /dev/null
+++ b/parameciofast/libraries/session.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+
+from settings import config
+from itsdangerous.url_safe import URLSafeTimedSerializer
+
+import os
+try:
+ import ujson as json
+except:
+ import json
+
+# Cookie session
+# This save the session in a cookie for maximum performance. In next version i can use memcached or something for session
+# In next versions have two secret_keys for more security.
+
+class ParamecioSession:
+
+ def __init__(self, session_cookie):
+
+ self.serializer=URLSafeTimedSerializer(config.secret_key)
+
+ try:
+
+ self.session=self.serializer.loads(session_cookie)
+ pass
+
+ except:
+ self.session={}
+
+ def get(self, name, default_value):
+
+ if not name in self.session:
+ self.session[name]=default_value
+
+ return self.session[name]
+
+ def __getitem__(self, key):
+
+ return self.session[key]
+
+ def __setitem__(self, key, value):
+
+ self.session[key]=value
+
+ def __delitem__(self, key):
+
+ if key!='token':
+ del self.session[key]
+
+ def __contains__(self, key):
+
+ if key in self.session:
+ return True
+ else:
+ return False
+
+ def __iter__(self):
+ return self.session
+
+ def __str__(self):
+ return self.session.__str__()
+
+ def keys(self):
+ return self.session.keys()
+ """
+ def remove(self):
+ response.delete_cookie(config.cookie_name, path="/")
+
+ def delete(self):
+ self.remove()
+
+ def save(self):
+
+ # Here get the function for load session
+
+ #save_session(self.session['token'], self.session)
+ pass
+ """
+
+ def save(self):
+
+ return self.serializer.dumps(session_cookie)
+
+ def reset(self):
+
+ #token=self.session['token']
+ self.session={}
+ #self.save()
+
diff --git a/parameciofast/libraries/urls.py b/parameciofast/libraries/urls.py
new file mode 100644
index 0000000..4f621e5
--- /dev/null
+++ b/parameciofast/libraries/urls.py
@@ -0,0 +1,133 @@
+"""
+Paramecio2fm is a series of wrappers for Flask, mako and others and construct a simple headless cms.
+
+Copyright (C) 2023 Antonio de la Rosa Caballero
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+"""
+
+try:
+ from settings import config
+except:
+
+ class config:
+
+ application_root='/'
+ domain_url='http://localhost/'
+ yes_static=False
+
+import urllib.parse
+
+def make_url(path, query_args={}):
+
+ """
+ This is a method for create urls for the system
+
+ Keyword arguments:
+ path -- The path to the module
+ query_args -- a ser of get variables for add to url
+
+ """
+
+ get_query=''
+
+ if len(query_args)>0:
+
+ get_query='?'+urllib.parse.urlencode(query_args)
+
+ path=urllib.parse.quote_plus(path, safe='/')
+
+ return config.application_root+path+get_query
+
+def make_url_domain(path, query_args={}):
+
+ """
+ This is a method for create urls for the system, using the domain_url config variable how prefix
+
+ Keyword arguments:
+ path -- The path to the module
+ query_args -- a ser of get variables for add to url
+
+ """
+
+ return config.domain_url+make_url(path, query_args)
+
+def make_external_url(path, query_args={}):
+
+ """
+ This is a method for create urls for external systems
+
+ Keyword arguments:
+ path -- The base url of the url
+ query_args -- a ser of get variables for add to url
+
+ """
+
+ get_query=''
+
+ if len(query_args)>0:
+
+ get_query='?'+urllib.parse.urlencode(query_args)
+
+ return path+get_query
+
+def add_get_parameters(url, **args):
+
+ """
+ This is a method for add args to existent url
+
+ Keyword arguments:
+ url -- The url
+ args -- a ser of get variables for add to url
+
+ """
+
+ added_url='&'
+
+ if url.find('?')==-1:
+ added_url='?'
+
+ get_query=urllib.parse.urlencode(args)
+
+ return url+added_url+get_query
+
+
+if config.yes_static==True:
+
+ def make_media_url(file_path, module):
+
+ """
+ This is a method for create urls for media resources.
+
+ Keyword arguments:
+ file_path -- The relative path of module
+ module -- the module where you can find the resource
+ """
+
+ return make_url('mediafrom/'+module+'/'+file_path)
+ #config.media_url+'mediafrom/'+module+'/'+file_path
+else:
+
+ def make_media_url(file_path, module):
+
+ """
+ This is a method for create urls for media resources if config.yes_static is disabled..
+
+ Keyword arguments:
+ file_path -- The relative path of module
+ module -- the module where you can find the resource
+
+ """
+
+ return config.media_url+urllib.parse.quote_plus(module)+'/'+urllib.parse.quote_plus(file_path, safe='/')
diff --git a/parameciofast/modules/fastadmin/app.py b/parameciofast/modules/fastadmin/app.py
index 646d993..6567f65 100644
--- a/parameciofast/modules/fastadmin/app.py
+++ b/parameciofast/modules/fastadmin/app.py
@@ -3,6 +3,11 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from parameciofast.modules.fastadmin import admin_app
from typing import Annotated
from parameciofast.fast import app
+from parameciofast.libraries.i18n import I18n
+from parameciofast.libraries.mtemplates import env_theme, PTemplate
+
+env=env_theme(__file__)
+t=PTemplate(env, app.url_path_for)
@admin_app.get('/', response_class=HTMLResponse)
def home_admin(request: Request, paramecio_session: Annotated[str | None, Cookie(description='Cookie for validate into the admin site. The cookie name can change in you settings/config.py')] = None):
@@ -17,8 +22,10 @@ def home_admin(request: Request, paramecio_session: Annotated[str | None, Cookie
@admin_app.get('/login', response_class=HTMLResponse)
-def login_admin():
+def login_admin(request: Request):
+ #session=request.session
+ i18n=I18n('admin', I18n.session_lang(request))
- return "Login"
+ return t.load_template('login.phtml', title=i18n.tlang('Login'), tlang=i18n.tlang, url_for=app.url_path_for)
diff --git a/parameciofast/modules/fastadmin/media/css/layout.css b/parameciofast/modules/fastadmin/media/css/layout.css
new file mode 100644
index 0000000..0df334a
--- /dev/null
+++ b/parameciofast/modules/fastadmin/media/css/layout.css
@@ -0,0 +1,78 @@
+/* Loader page */
+
+.loader-div {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100vh;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index:999999;
+ /*background: url('../images/caledonian.png') no-repeat center 40%;
+ background-color: #ec1c24;*/
+ background: transparent;
+}
+
+.loader {
+ position: relative;
+ width: 10vw;
+ height: 5vw;
+ padding: 1.5vw;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.loader span {
+ position: absolute;
+ height: 0.8vw;
+ width: 0.8vw;
+ border-radius: 50%;
+ background-color: #fff;
+}
+
+.loader span:nth-child(1) {
+ animation: loading-dotsA 0.5s infinite linear;
+}
+
+.loader span:nth-child(2) {
+ animation: loading-dotsB 0.5s infinite linear;
+}
+
+@keyframes loading-dotsA {
+ 0% {
+ transform: none;
+ }
+ 25% {
+ transform: translateX(2vw);
+ }
+ 50% {
+ transform: none;
+ }
+ 75% {
+ transform: translateY(2vw);
+ }
+ 100% {
+ transform: none;
+ }
+}
+
+@keyframes loading-dotsB {
+ 0% {
+ transform: none;
+ }
+ 25% {
+ transform: translateX(-2vw);
+ }
+ 50% {
+ transform: none;
+ }
+ 75% {
+ transform: translateY(-2vw);
+ }
+ 100% {
+ transform: none;
+ }
+}
diff --git a/parameciofast/modules/fastadmin/templates/login.phtml b/parameciofast/modules/fastadmin/templates/login.phtml
new file mode 100644
index 0000000..18fef6b
--- /dev/null
+++ b/parameciofast/modules/fastadmin/templates/login.phtml
@@ -0,0 +1,71 @@
+
+
+