Compare commits

...
2 Commits
Author SHA1 Message Date
franzz da47b39315 forgot log files 2026-09-03 18:29:04 +02:00
franzz 549a7e0fa0 v3 init push 2026-09-03 18:28:33 +02:00
110 changed files with 8734 additions and 4873 deletions
+15 -4
View File
@@ -1,4 +1,15 @@
/.project
/settings.php
/.buildpath
/.settings/
# App config files
/config/settings.php
/log.html
# Build folder
/public/*
!/public/index.php
# Dependencies files
/vendor/
/node_modules/
/composer.dev.lock
# Lint caches
/.php-cs-fixer.cache
-3
View File
@@ -1,3 +0,0 @@
## Jul - deny access to the top-level git repository
RewriteEngine On
RewriteRule \.git - [F,L]
+38
View File
@@ -0,0 +1,38 @@
<?php
/*
* PHP-CS-Fixer config codifying the style in use under lib/ and public/.
* Deliberately NOT based on @PSR2/@PSR12 - this codebase diverges from those
* on purpose (tabs, no space before control-structure parens), so a preset
* would rewrite most of it to match a style nobody here uses.
*/
$finder = (new PhpCsFixer\Finder())
->in([__DIR__.'/lib', __DIR__.'/public'])
->append([__DIR__.'/config/settings-sample.php'])
->name('*.php');
return (new PhpCsFixer\Config())
->setIndent("\t")
->setLineEnding("\n")
->setRules([
'single_quote' => true,
'array_syntax' => ['syntax' => 'short'],
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line',
'anonymous_functions_opening_brace' => 'same_line'
],
'concat_space' => ['spacing' => 'none'],
'constant_case' => ['case' => 'lower'],
'lowercase_keywords' => true,
'visibility_required' => ['elements' => ['method', 'property', 'const']],
'no_unused_imports' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'single_blank_line_at_eof' => true,
'no_empty_statement' => true,
'trim_array_spaces' => true,
'new_with_parentheses' => true
])
->setFinder($finder);
-19
View File
@@ -1,19 +0,0 @@
<?php
define('IMAGE_PATH', 'images/');
//background selection
$asImages = glob(IMAGE_PATH.'{*.jpg,*.png,*.jpeg,*.gif}', GLOB_BRACE);
$sRandomImagePath = $asImages[array_rand($asImages)];
//get type
$sFileInfo = pathinfo($sRandomImagePath);
$sExt = strtolower($sFileInfo['extension']);
if($sExt=='jpg')
{
$sExt = 'jpeg';
}
//display image
header("Content-type: image/$sExt");
echo file_get_contents($sRandomImagePath);
+35
View File
@@ -0,0 +1,35 @@
{
"name": "franzz/mythoughts",
"description": "MyThoughts",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "path",
"url": "../objects",
"options": {
"symlink": true
}
}
],
"require": {
"php": ">=8.4",
"franzz/objects": "dev-vue"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\MyThoughts\\": "lib/",
"Franzz\\Objects\\": "../objects/inc/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "franzz/mythoughts",
"description": "MyThoughts",
"type": "project",
"license": "GPL-3.0-or-later",
"repositories": [
{
"type": "git",
"url": "https://git.lutran.fr/franzz/objects"
}
],
"require": {
"php": ">=8.4",
"franzz/objects": "dev-vue"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.75"
},
"autoload": {
"psr-4": {
"Franzz\\MyThoughts\\": "lib/"
},
"files": [
"config/settings.php"
]
},
"scripts": {
"lint": "php-cs-fixer fix --dry-run --diff",
"lint-fix": "php-cs-fixer fix"
}
}
-996
View File
@@ -1,996 +0,0 @@
<?php
/**
* TODO List
* save before quit / read
* Signature automatique (ajouter dans settings)
* connexion : diary cover + cadenas
* writing pad : open book (2 pages) ou plusieurs feuilles superposées
* separate textarea / text layout
* all hovering / active images on the same one + positioning
* thougt layout ! replace " with image quote
*/
class PhpObject
{
private $asMessageStack;
private $iExtractMode;
const ERROR_TAB = 'error';
const WARNING_TAB = 'warning';
const MODE_ARRAY = 0;
const MODE_TEXT = 1;
const MODE_FILE = 2;
function __construct()
{
$this->asMessageStack = array();
$this->asMessageStack[self::ERROR_TAB] = array();
$this->asMessageStack[self::WARNING_TAB] = array();
$this->iExtractMode = self::MODE_ARRAY;
}
protected function addError($sError)
{
$this->addMessage(self::ERROR_TAB, $sError);
}
protected function addWarning($sWarning)
{
$this->addMessage(self::WARNING_TAB, $sError);
}
private function addMessage($sType, $sMessage)
{
$this->asMessageStack[$sType][] = $sMessage;
}
protected function getCleanErrorStack()
{
return $this->getCleanMessageStack(self::ERROR_TAB);
}
protected function getCleanWarningStack()
{
return $this->getCleanMessageStack(self::WARNING_TAB);
}
private function getCleanMessageStack($sType)
{
switch($this->iExtractMode)
{
case self::MODE_TEXT:
$oMessageStack = implode("\n", $this->asMessageStack[$sType]);
break;
case self::MODE_ARRAY:
$oMessageStack = $this->asMessageStack[$sType];
break;
case self::MODE_FILE:
$oMessageStack = implode("</p><p>", $this->asMessageStack[$sType]);
break;
}
$this->asMessageStack[$sType] = array();
return $oMessageStack;
}
function __destruct()
{
$sErrorStack = $this->getCleanErrorStack();
switch($this->iExtractMode)
{
case self::MODE_TEXT:
echo $sErrorStack;
break;
case self::MODE_ARRAY:
if(count($sErrorStack)>0)
{
pre($sErrorStack, 'Error Stack');
}
break;
case self::MODE_FILE:
if($sErrorStack!='')
{
@file_put_contents('log.html', '<p style="font-weight:bold;">'.date('r')."</p><p>".$sErrorStack.'</p>', FILE_APPEND);
}
break;
}
}
}
class Session extends PhpObject
{
private $iUserId;
private $sLogin;
private $sToken;
private $sPostToken;
private $oMySql;
const SESSION_ID_USER = 'id_user';
const SESSION_USER = 'user';
const SESSION_TOKEN = 'token';
const SESSION_POST_TOKEN = 'post_token';
public function __construct($oMySql)
{
parent::__construct();
$this->iUserId = $this->sLogin = $this->sToken = $this->sPostToken = false;
$this->oMySql = $oMySql;
$this->syncSession();
}
public function getUserId()
{
return $this->iUserId;
}
private function syncSession()
{
if(isset($_SESSION[self::SESSION_ID_USER]))
{
$this->iUserId = $_SESSION[self::SESSION_ID_USER];
}
if(isset($_SESSION[self::SESSION_USER]))
{
$this->sLogin = $_SESSION[self::SESSION_USER];
}
if(isset($_SESSION[self::SESSION_TOKEN]))
{
$this->sToken = $_SESSION[self::SESSION_TOKEN];
}
if(isset($_SESSION[self::SESSION_POST_TOKEN]))
{
$this->sPostToken = $_SESSION[self::SESSION_POST_TOKEN];
}
}
private function setSession($iUserId, $sLogin)
{
$_SESSION[self::SESSION_ID_USER] = $iUserId;
$_SESSION[self::SESSION_USER] = $sLogin;
//Token
$sToken = $this->createToken();
$_SESSION[self::SESSION_TOKEN] = $sToken;
$this->setTokenCookie($sToken);
$this->syncSession();
}
public function register($asData)
{
$sLogin = strtolower($asData['user']);
$sPass = $asData['pass'];
if($sLogin=='' || $sPass=='')
{
$this->addError('Empty mandatory fields (Nickname or password)');
}
elseif(htmlspecialchars($sLogin, ENT_QUOTES)!=$sLogin)
{
$this->addError('Nickname: HTML characters are forbidden');
}
elseif($this->oMySql->selectRow(MySqlManager::USERS_TABLE, array('user'=>$sLogin)))
{
$this->addError('Nickname: This is already a user called by that name, choose a different one');
}
else
{
$asData['pass'] = self::encryptPassword($sPass);
$iUserId = $this->oMySql->insertRow(MySqlManager::USERS_TABLE, $asData);
return $this->logMeIn($sLogin, $sPass);
}
return false;
}
public function logMeIn($sLogin, $sPass)
{
$bResult = false;
$asUser = $this->oMySql->selectRow(MySqlManager::USERS_TABLE, array('user'=>$sLogin));
if(!$asUser)
{
$this->addError('Utilisateur inconnu');
}
elseif(!$this->checkPassword($sPass, $asUser['pass']))
{
$this->addError('mot de pass incorrect');
}
else
{
$this->setSession($asUser[MySqlManager::getId(MySqlManager::USERS_TABLE)], $sLogin);
$bResult = true;
}
return $bResult;
}
public function isLogguedIn()
{
$bLogguedIn = false;
if($this->iUserId && $this->sLogin && $this->sToken)
{
//check if token is set and valid
if($this->checkToken())
{
//Check if user got a actual account in the database
$bLogguedIn = $this->checkAccount($this->sLogin, $this->iUserId);
}
else
{
$this->addError('Authentication problem, please sign in again');
}
}
return $bLogguedIn;
}
private function checkAccount($sUserName, $iUserId=0)
{
$asConstraints = array('user'=>$sUserName);
if($iUserId>0)
{
$asConstraints[MySqlManager::getId(MySqlManager::USERS_TABLE)] = $iUserId;
}
return $this->oMySql->selectValue(MySqlManager::USERS_TABLE, 'COUNT(1)', $asConstraints);
}
public function logMeOut()
{
$_SESSION = array();
$this->setTokenCookie('', -1);
return session_destroy();
}
private static function encryptPassword($sPass)
{
$sRandomText = 'F_RA-1H"2{bvj)5f?0sd3r#fP,K]U|w}hGiN@(sZ.sDe!7*x/:Mq+&';
for($iIndex=0; $iIndex < strlen($sPass); $iIndex++)
{
$sPass[$iIndex] = $sRandomText[$iIndex%strlen($sRandomText)] ^ $sPass[$iIndex];
}
return md5($sPass);
}
private static function createToken()
{
return self::encryptPassword( $_SERVER['HTTP_USER_AGENT'].
$_SERVER['REMOTE_ADDR'].
$_SERVER['REQUEST_TIME'].
strstr(microtime(), ' ', true).
$_SERVER['SERVER_SIGNATURE'].
$_SERVER['SERVER_ADMIN']);
}
//Session Token
private static function setTokenCookie($sToken, $iTime=1)
{
setcookie(self::SESSION_TOKEN, $sToken, time()+60*60*24*$iTime);
$_COOKIE[self::SESSION_TOKEN] = $sToken;
}
private function checkToken()
{
return ($this->sToken && array_key_exists(self::SESSION_TOKEN, $_COOKIE) && $_COOKIE[self::SESSION_TOKEN] == $this->sToken);
}
//Post Token
private function refreshPostToken()
{
$this->sPostToken = self::createToken();
$_SESSION[self::SESSION_POST_TOKEN] = $this->sPostToken;
}
public function getNewPostToken()
{
$this->refreshPostToken();
return $this->sPostToken;
}
public function checkPostToken($sPostToken)
{
return ($this->sPostToken && $sPostToken!='' && $sPostToken == $this->sPostToken);
}
private static function checkPassword($sClearPass, $sEncodedPass)
{
return self::encryptPassword($sClearPass) == $sEncodedPass;
}
}
class Mask extends PhpObject
{
public $sMaskName;
public $sFilePath;
private $sMask;
private $asTags;
private $asPartsSource;
private $aoInstances;
const MASK_FOLDER = 'mask/';
const START_TAG = 'START';
const END_TAG = 'END';
const TAG_MARK = '#';
public function __construct($sFileName='')
{
//init
parent::__construct();
$this->sMaskName = '';
$this->sFilePath = '';
$this->sMask = '';
$this->asTags = array();
$this->asPartsSource = array();
$this->aoInstances = array();
$this->sFilePath = '';
//load file
if($sFileName!='')
{
$this->initFile($sFileName);
}
}
public function initFile($sFileName)
{
$sFilePath = self::MASK_FOLDER.$sFileName.'.html';
if(file_exists($sFilePath))
{
$this->sFilePath = $sFilePath;
$sSource = file_get_contents($this->sFilePath);
$this->initMask($sFileName, $sSource);
}
else
{
$this->addError('Fichier introuvable &agrave; l\'adresse : '.$sFilePath);
}
}
public function initFileFromString($sSource, $sPartName='', $iInstanceNb=0)
{
$this->initMask($sPartName.' (from row) '.$iInstanceNb, $sSource);
}
private function initMask($sMaskName, $sSource)
{
$this->sMaskName = $sMaskName;
$this->sMask = $sSource;
$this->setParts();
}
private function setParts()
{
while(preg_match('/\<\!-- \[PART\] (?P<part>\S+) \[START\] --\>/', $this->sMask, $asMatch))
{
$sPartName = $asMatch['part'];
$this->asPartsSource[$sPartName] = $this->getCleanPart($sPartName);
$this->aoInstances[$sPartName] = array();
}
}
private function getCleanPart($sPartName)
{
$iStartPos = $this->getPartStartPos($sPartName);
$iEndPos = $this->getPartEndPos($sPartName);
$sPart = substr($this->sMask, $iStartPos, $iEndPos-$iStartPos);
$sExtendedPart = $this->getPartPattern($sPartName, self::START_TAG).$sPart. $this->getPartPattern($sPartName, self::END_TAG);
$this->sMask = str_replace($sExtendedPart, $this->getPartTagPattern($sPartName), $this->sMask);
return $sPart;
}
private function getPartStartPos($sPartName)
{
$sPartStartPattern = $this->getPartPattern($sPartName, self::START_TAG);
return strpos($this->sMask, $sPartStartPattern) + strlen($sPartStartPattern);
}
private function getPartEndPos($sPartName)
{
$sPartEndPattern = $this->getPartPattern($sPartName, self::END_TAG);
return strpos($this->sMask, $sPartEndPattern);
}
private function getPartPattern($sPartName, $sAction)
{
return '<!-- [PART] '.$sPartName.' ['.$sAction.'] -->';
}
private function getPartTagPattern($sPartName, $bMark=true)
{
$sPartTag = 'PART '.$sPartName;
return $bMark?$this->addTagMark($sPartTag):$sPartTag;
}
public function addInstance($sPartName, $asTags)
{
$this->newInstance($sPartName);
foreach($asTags as $sTagName=>$sTagValue)
{
$this->setInstanceTag($sPartName, $sTagName, $sTagValue);
}
}
public function newInstance($sPartName)
{
//Finding the part
$oMask = &$this->findPart($this, $sPartName);
//Retrieving source html
$sPartSource = $oMask->asPartsSource[$sPartName];
//Creating new instance
$oInstance = new Mask();
$oInstance->initFileFromString($sPartSource, $sPartName);
$oMask->aoInstances[$sPartName][] = $oInstance;
}
public function setInstanceTag($sPartName, $sTagName, $sTagValue)
{
$oMask = &$this->findPart($this, $sPartName);
$oMask->getCurrentInstance($sPartName)->setTag($sTagName, $sTagValue);
}
private function &findPart($oMask, $sPartName)
{
if(array_key_exists($sPartName, $oMask->aoInstances))
{
return $oMask;
}
else //not tested
{
foreach($oMask->aoInstances as $sLevelPartName=>$aoInstances)
{
if(!empty($aoInstances))
{
//take last instances
return $this->findPart($oMask->getCurrentInstance($sLevelPartName), $sPartName);
}
}
}
$this->addError('No part found : '.$sPartName);
}
private function getCurrentInstance($sPartName)
{
if(!empty($this->aoInstances[$sPartName]))
{
return end($this->aoInstances[$sPartName]);
}
else
{
return false;
}
}
public function setTag($sTagName, $sTagValue)
{
$this->asTags[$sTagName] = $sTagValue;
}
public function getMask()
{
$sCompletedMask = $this->sMask;
//build parts
foreach($this->aoInstances as $sPart=>$aoParts)
{
$sTagValue = '';
foreach($aoParts as $oInstance)
{
$sTagValue .= $oInstance->getMask();
}
$this->setTag($this->getPartTagPattern($sPart, false), $sTagValue);
}
//replace tags
if(!empty($this->asTags))
{
$asTags = $this->addTagMark(array_keys($this->asTags));
$sCompletedMask = str_replace($asTags, $this->asTags, $sCompletedMask);
}
return $sCompletedMask;
}
private function addTagMark($oData)
{
return array_map_encapsulate($oData, self::TAG_MARK);
}
}
class MyThoughts extends PhpObject
{
//Constants
const URL_DATE_FORMAT = 'Ymd';
const LAYOUT_DATE_FORMAT = 'F \t\h\e jS, Y';
const MYSQL_DATE_FORMAT = 'Y-m-d';
const LAYOUT_TIME_FORMAT = 'G:i';
const WELCOME_MSG_FILE = 'welcome';
//settings
const SETTING_LAYOUT = 'layout';
const LAYOUT_ONE_PAGE = '1';
const LAYOUT_TWO_PAGES = '2';
const SETTING_FONT = 'font';
const FONT_THOUGHTS = 'thoughts';
const FONT_ARIAL = 'Arial';
const FONT_VERDANA = 'Verdana';
const SETTING_SIZE = 'Size';
const SIZE_16 = '16';
const SIZE_18 = '18';
const SIZE_20 = '20';
//Objects
private $oMySql;
private $oSession;
private $oCalendar;
private $asSettings;
//Masks
private $oMainMask;
private $oPageMask;
private $oMenuMask;
function __construct()
{
parent::__construct();
$this->oMySql = new MySqlManager();
$this->oSession = new Session($this->oMySql);
$this->oCalendar = new Calendar($this->oMySql, $this->oSession);
$this->oMainMask = new Mask('index');
$this->oPageMask = new Mask();
$this->oMenuMask = new Mask();
$this->asSettings = array();
}
public function logMeOut()
{
$this->oSession->logMeOut();
self::relocate();
}
public function isLogguedIn()
{
return $this->oSession->isLogguedIn();
}
public function checkPostToken($sPostToken)
{
return $this->oSession->checkPostToken($sPostToken);
}
public function setPage($sPage)
{
$this->oPageMask->initFile($sPage);
}
public function setPageTitle($sTitle)
{
$this->oMainMask->setTag('title', $sTitle);
}
public function setCalendarDate($iYear=0, $iMonth=0)
{
$this->oCalendar->setDate($iYear, $iMonth);
}
private static function relocate($sPage='', $asVar=array())
{
$asVar['p'] = $sPage;
header('Location:index.php?'.implodeAll($asVar, '=', '&'));
die();
}
public function updateThought($iThoughtId, $sThought)
{
$asValues = array('thought'=>$this->encodeThought($sThought));
$asConstraints = array( MySqlManager::getId(MySqlManager::THOUGHTS_TABLE)=>$iThoughtId,
MySqlManager::getId(MySqlManager::USERS_TABLE)=>$this->oSession->getUserId());
$this->oMySql->updateRow(MySqlManager::THOUGHTS_TABLE, $asConstraints, $asValues);
}
private function shuffleText($sText)
{
$sRandomText = "let's_mess%a&bit;with~it,!just§for¨the^sake*of-it";
for($iIndex=0; $iIndex < strlen($sText); $iIndex++)
{
$sText[$iIndex] = $sRandomText[$iIndex%strlen($sRandomText)] ^ $sText[$iIndex];
}
return $sText;
}
public function activateMenu()
{
$this->oMenuMask->initFile('menu');
}
//settings
public static function getSettingsList()
{
//TODO Save on database (param table)
return array(self::SETTING_FONT, self::SETTING_SIZE, self::SETTING_LAYOUT);
}
private static function getDefaultSetting($sSettingName)
{
switch($sSettingName)
{
case self::SETTING_FONT:
return self::FONT_THOUGHTS;
case self::SETTING_LAYOUT:
return self::LAYOUT_ONE_PAGE;
}
return false;
}
private function getSetting($sSettingName)
{
if(!array_key_exists($sSettingName, $this->asSettings))
{
$asConstraint = array(MySqlManager::getText(MySqlManager::SETTINGS_TABLE)=>$sSettingName, MySqlManager::getId(MySqlManager::USERS_TABLE)=>$this->oSession->getUserId());
$oValue = $this->oMySql->selectValue(MySqlManager::SETTINGS_TABLE, 'value', $asConstraint);
$this->asSettings[$sSettingName] = (!$oValue)?self::getDefaultSetting($sSettingName):$oValue;
}
return $this->asSettings[$sSettingName];
}
private function setSetting($sValue, $sSettingName)
{
$this->oMySql->insertUpdateRow(MySqlManager::SETTINGS_TABLE, array('setting'=>$sSettingName, MySqlManager::getId(MySqlManager::USERS_TABLE)=>$this->oSession->getUserId()), array('value'=>$sValue));
}
public function setSettings($asSettings)
{
array_walk($asSettings, array($this, 'setSetting'));
}
/* Pages */
public function logonPage($sPreviousLogin)
{
$this->setPageTitle('Login');
$this->setPage('logon');
$sPreviousLogin = ($sPreviousLogin=='')?'':$sPreviousLogin;
$this->oPageMask->setTag('login', $sPreviousLogin);
}
public function writingPage($iThoughtId=0)
{
$sThought = '';
$iThoughtTime = '';
if($iThoughtId!=0)
{
//load a thought
$asConstraints = array( MySqlManager::getId(MySqlManager::THOUGHTS_TABLE)=>$iThoughtId,
MySqlManager::getId(MySqlManager::USERS_TABLE)=>$this->oSession->getUserId());
$asThought = $this->oMySql->selectRow(MySqlManager::THOUGHTS_TABLE, $asConstraints);
$sThought = $this->decodeThought($asThought['thought']);
$iThoughtTime = 'Saved at '.date(self::LAYOUT_TIME_FORMAT, strtotime($asThought['led']));
}
$this->setPage('write_thought');
$this->oPageMask->setTag('font', $this->getSetting(self::SETTING_FONT));
$this->oPageMask->setTag('size', $this->getSetting(self::SETTING_SIZE));
$this->oPageMask->setTag('thought', $sThought);
$this->oPageMask->setTag('thought_id', $iThoughtId);
$this->oPageMask->setTag('last_saved', $iThoughtTime);
$this->setPageTitle('Talk to me');
}
public function readingPage($iTimeStamp=0)
{
if($iTimeStamp==0)
{
$iTimeStamp = strtotime('now');
}
$sMySqlDate = date(self::MYSQL_DATE_FORMAT, $iTimeStamp);
$sLayoutDate = date(self::LAYOUT_DATE_FORMAT, $iTimeStamp);
$asConstraints = array('DATE(led)'=>$sMySqlDate, MySqlManager::getId(MySqlManager::USERS_TABLE)=>$this->oSession->getUserId());
$asThougths = $this->oMySql->selectRows(array('from'=>MySqlManager::THOUGHTS_TABLE, 'constraint'=>$asConstraints));
$this->setPage('read_thought');
$this->setPageTitle('Thoughts on '.$sLayoutDate);
$this->oPageMask->setTag('date', $sLayoutDate);
foreach($asThougths as $asThought)
{
$asThoughtParagraphs = explode("\n", $this->decodeThought($asThought['thought']));
$this->oPageMask->newInstance('THOUGHT');
$this->oPageMask->setInstanceTag('THOUGHT', 'time', date(self::LAYOUT_TIME_FORMAT, strtotime($asThought['led'])));
foreach($asThoughtParagraphs as $sParagraph)
{
$asParagraphTags = array('thought_paragraph'=>$sParagraph);
$this->oPageMask->addInstance('THOUGHT_PARA', $asParagraphTags);
}
}
//calendar update
$this->setCalendarDate(date('Y', $iTimeStamp), date('m', $iTimeStamp));
return (count($asThougths)>0);
}
public function settingsPage()
{
$this->setPage('settings');
$this->setPageTitle('Settings');
$asSettingsOptions = array( self::SETTING_LAYOUT => array(
'One extensible page' => self::LAYOUT_ONE_PAGE,
'Two Pages, Diary like' => self::LAYOUT_TWO_PAGES),
self::SETTING_FONT => array(
'AES Crawl' => self::FONT_THOUGHTS,
'Arial' => self::FONT_ARIAL,
'Verdana' => self::FONT_VERDANA),
self::SETTING_SIZE => array(
'16pt' => self::SIZE_16,
'18pt' => self::SIZE_18,
'20pt' => self::SIZE_20));
foreach(self::getSettingsList() as $sSettingName)
{
$this->oPageMask->newInstance('SETTING');
$this->oPageMask->setInstanceTag('SETTING', 'setting_name', $sSettingName);
$sUserSetting = $this->getSetting($sSettingName);
foreach($asSettingsOptions[$sSettingName] as $sOptionName=>$sOptionValue)
{
if($sOptionValue == self::getDefaultSetting($sSettingName))
{
$sOptionName .= ' (Default)';
}
$sSelectedOption = ($sUserSetting==$sOptionValue)?'selected':'';
$asSettingOptions = array( 'setting_option_value'=>$sOptionValue,
'setting_option_selected'=>$sSelectedOption,
'setting_option_name'=>$sOptionName);
$this->oPageMask->addInstance('SETTING_OPTION', $asSettingOptions);
}
}
}
/* Final processes */
private function collectErrors()
{
$asErrors = array_merge($this->getCleanErrorStack(),
$this->oMySql->getCleanErrorStack(),
$this->oSession->getCleanErrorStack(),
$this->oCalendar->getCleanErrorStack(),
$this->oMainMask->getCleanErrorStack(),
$this->oPageMask->getCleanErrorStack(),
$this->oMenuMask->getCleanErrorStack());
//$asErrors = array_map('getCleanErrorStack', array($this, $this->oMySql, $this->oSession, $this->oCalendar, $this->oMainMask, $this->oPageMask, $this->oMenuMask));
//pre($asErrors, 'static error stock', true);
$oErrorMask = new Mask();
if(!empty($asErrors))
{
$oErrorMask->initFile('errors');
foreach($asErrors as $sError)
{
$oErrorMask->addInstance('ERROR', array('error'=>$sError));
}
}
return $oErrorMask->getMask();
}
}
class Calendar extends PhpObject
{
const CAL_YEAR = 'cy';
const CAL_MONTH = 'cm';
private $oMySql;
private $oSession;
private $oMask;
private $iUserId;
private $iYear;
private $iMonth;
function __construct($oMySql, $oSession)
{
parent::__construct();
$this->oMySql = $oMySql;
$this->oSession = $oSession;
$this->oMask = new Mask('calendar');
$this->iYear = 0;
$this->iMonth = 0;
}
public function setDate($iYear=0, $iMonth=0)
{
if($iYear==0)
{
$iYear = date('Y');
}
if($iMonth==0)
{
$iMonth = date('m');
}
$this->iYear = $iYear;
$this->iMonth = $iMonth;
}
private function getThoughts()
{
//TODO essayer avec selectRows
$sQuery = "SELECT DATE_FORMAT(led, '%d') AS day
FROM ".MySqlManager::THOUGHTS_TABLE."
WHERE ".MySqlManager::getId(MySqlManager::USERS_TABLE)." = ".$this->oSession->getUserId()."
AND YEAR(led) = ".$this->iYear."
AND MONTH(led) = ".$this->iMonth."
GROUP BY day
ORDER BY day";
return $this->oMySql->getArrayQuery($sQuery, true);
}
private function getUpdatedLink($asParams)
{
$sCurrentVariables = $_SERVER['QUERY_STRING'];
$asCurrentVariables = explode('&', $sCurrentVariables);
foreach($asCurrentVariables as $sParam)
{
$sKey = strstr($sParam, '=', true);
$sValue = substr(strstr($sParam, '='), 1);
$asVariables[$sKey] = $sValue;
}
return '?'.implodeAll(array_merge($asVariables, $asParams), '=', '&');
}
private function getLink($iOffset)
{
$iTimeStamp = mktime(0, 0, 0, $this->iMonth + $iOffset, 1, $this->iYear);
return $this->getUpdatedLink(array(self::CAL_MONTH=>date('n', $iTimeStamp), self::CAL_YEAR=>date('Y', $iTimeStamp)));
}
private function setMaskItems()
{
//week starting on the sunday : offset = 0, monday : offset = 1
$iOffset = 1;
//days in the month
$iMonthLastDay = date('d', mktime(0, 0, 0, $this->iMonth+1, 0, $this->iYear));
$asDays = range(1, $iMonthLastDay);
$iDayNb = 1 - date($iOffset?'N':'w', mktime(0, 0, 0, $this->iMonth, 1, $this->iYear)) + $iOffset;
$iCalendarLastDay = $iMonthLastDay + (7 - date($iOffset?'N':'w', mktime(0, 0, 0, $this->iMonth+1, 0, $this->iYear))) + $iOffset;
//days with thoughts
$asThoughts = $this->getThoughts();
while($iDayNb < $iCalendarLastDay)
{
$iCurrentDayTimeStamp = mktime(0, 0, 0, $this->iMonth, $iDayNb, $this->iYear);
$sItemDate = date('d', $iCurrentDayTimeStamp);
//new week
if(date('w', $iCurrentDayTimeStamp) == $iOffset)
{
$this->oMask->newInstance('WEEK');
}
//day within month
if(date('n', $iCurrentDayTimeStamp)==$this->iMonth)
{
$bThoughts = in_array($iDayNb, $asThoughts);
$sItemClass = $bThoughts?'full':'empty';
$sItemLink = $bThoughts?$this->getUpdatedLink(array('d'=>date(MyThoughts::URL_DATE_FORMAT, $iCurrentDayTimeStamp), 'p'=>'r')):'#';
$sItemLinkTitle = $bThoughts?'See my thoughts on '.date(MyThoughts::LAYOUT_DATE_FORMAT, $iCurrentDayTimeStamp):'';
}
else
{
$sItemClass = 'disabled';
$sItemLink = '#';
$sItemLinkTitle = '';
}
$this->oMask->addInstance('DAY', array('item_day'=>$sItemDate, 'item_class'=>$sItemClass, 'item_link'=>$sItemLink, 'item_link_title'=>$sItemLinkTitle));
$iDayNb++;
}
//column titles
$asDayNames = array('1'=>'Mon', '2'=>'Tue', '3'=>'Wed', '4'=>'Thu', '5'=>'Fri', '6'=>'Sat', $iOffset?'7':'0'=>'Sun');
ksort($asDayNames);
foreach($asDayNames as $sDayName)
{
$this->oMask->addInstance('TITLE', array('day_name'=>$sDayName));
}
}
public function getCalendar()
{
$sResult = '';
if($this->iYear!=0 && $this->iMonth!=0)
{
$this->oMask->setTag('link_prev', $this->getLink(-1));
$this->oMask->setTag('current_month', date('F', mktime(0, 0, 0, $this->iMonth, 1, $this->iYear)));
$this->oMask->setTag('link_next', $this->getLink(1));
$this->setMaskItems();
$sResult = $this->oMask->getMask();
}
return $sResult;
}
}
function arrayKeyFilter($asArray, $sCallBack)
{
$asValidKeys = array_flip(array_filter(array_keys($asArray), $sCallBack));
return array_intersect_key($asArray, $asValidKeys);
}
function array_map_encapsulate($oData, $sChar)
{
if(is_array($oData))
{
$asChar = array_fill(1, count($oData), $sChar);
return array_combine(array_keys($oData), array_map('array_map_encapsulate', $oData, $asChar));
}
else
{
return $sChar.$oData.$sChar;
}
}
function implodeAll($asText, $sKeyValueSeparator='', $sRowSeparator='', $sKeyPre='', $sValuePost=false)
{
if($sValuePost===false)
{
$sValuePost = $sKeyPre;
}
$asCombinedText = array();
foreach($asText as $sKey=>$sValue)
{
$asCombinedText[] = $sKeyPre.$sKey.$sKeyValueSeparator.$sValue.$sValuePost;
}
return implode($sRowSeparator, $asCombinedText);
}
function cleanPost(&$asData)
{
//get rid of magic quotes
if(function_exists('get_magic_quotes_gpc') && get_magic_quotes_gpc())
{
cleanData($asData, 'stripslashes');
}
}
function cleanData(&$oData, $sCleaningFunc)
{
if(!is_array($oData))
{
$oData = call_user_func($sCleaningFunc, $oData);
}
elseif(!empty($oData))
{
$asKeys = array_map($sCleaningFunc, array_keys($oData));
$asValues = array_map($sCleaningFunc, $oData);
$oData = array_combine($asKeys, $asValues);
}
}
//debug
function pre($sText, $sTitle='Test', $bDie=false, $bLog=false)
{
if($bLog)
{
file_put_contents('log', ($sTitle!=''?$sTitle." :\n":'').print_r($sText, true)."\n\n");
}
echo '<fieldset class="rounded"><legend class="rounded">'.$sTitle.'</legend><pre>'.print_r($sText, true).'</pre></fieldset>';
if($bDie)
{
die('[die() called by the test function '.__FUNCTION__.'()]');
}
}
?>
+46
View File
@@ -0,0 +1,46 @@
# Serve https://localhost/mythoughts/ from the public web root.
#
# Include this from the site VirtualHost (or paste the Alias/Directory block
# into the existing one). Everything outside public/ - lib/, config/, vendor/,
# node_modules/ - stays off the document root and is never web-reachable.
Alias /mythoughts /var/www/html/mythoughts/public
<Directory /var/www/html/mythoughts/public>
Options FollowSymLinks
AllowOverride None
Require all granted
DirectoryIndex index.php
<IfModule mod_headers.c>
# Vite writes content-hashed asset names, so they are safe to pin.
<FilesMatch "\.[0-9a-f]{8,}\.(js|css|woff2?|svg)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS \
text/html \
text/plain \
text/css \
text/javascript \
application/javascript \
application/json \
application/manifest+json \
image/svg+xml
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE \
text/html \
text/plain \
text/css \
text/javascript \
application/javascript \
application/json \
application/manifest+json \
image/svg+xml
</IfModule>
</Directory>
+13
View File
@@ -0,0 +1,13 @@
<?php
class Settings {
public const DB_SERVER = 'localhost';
public const DB_LOGIN = '';
public const DB_PASS = '';
public const DB_NAME = 'mythoughts';
public const DB_ENC = 'utf8mb4';
public const TEXT_ENC = 'UTF-8';
public const TIMEZONE = 'Europe/Zurich';
public const DEBUG = true;
public const LOG_FOLDER = __DIR__;
}
+69
View File
@@ -0,0 +1,69 @@
// ESLint flat config codifying the style already in use across the workspace's
// Vue projects: tabs, single quotes, `if(x)` with no space before the paren,
// and Hungarian-ish prefixes on locals. Goal is catching real mistakes, not
// importing an external style guide.
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import globals from 'globals';
export default [
js.configs.recommended,
...vue.configs['flat/essential'],
{
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.browser
}
},
rules: {
//Indentation: tabs everywhere. Left manual - the rule throws too
//many false positives on wrapped Vue template attributes.
'indent': 'off',
'no-tabs': 'off',
'quotes': ['warn', 'single', {avoidEscape: true, allowTemplateLiterals: true}],
//Semicolons everywhere except the top-level `export default {...}`
//in Vue SFCs, which ESLint cannot carve out - hence 'warn'.
'semi': ['warn', 'always'],
//Loose and strict equality are both used on purpose (numeric
//strings come back from the API) - do not force either.
'eqeqeq': 'off',
'space-before-function-paren': ['warn', 'never'],
'keyword-spacing': ['warn', {
after: true,
overrides: {
if: {after: false},
for: {after: false},
while: {after: false},
switch: {after: false},
catch: {after: false}
}
}],
'space-before-blocks': ['warn', 'always'],
//Object literals and destructuring use `{a, b}`, but imports use
//`import { x } from 'y'` - one rule cannot express both.
'object-curly-spacing': 'off',
'array-bracket-spacing': ['warn', 'never'],
'no-unused-vars': ['warn', {argsIgnorePattern: '^_', ignoreRestSiblings: true}],
'no-undef': 'error',
'no-var': 'error',
'prefer-const': 'off',
'vue/component-name-in-template-casing': 'off',
'vue/multi-word-component-names': 'off',
'vue/attribute-hyphenation': 'off',
'vue/require-default-prop': 'off',
'vue/no-v-html': 'error'
}
},
{
ignores: ['public/**', 'vendor/**', 'node_modules/**']
}
];
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

-174
View File
@@ -1,174 +0,0 @@
<?php
class Auth extends PhpObject
{
const ALGO = PASSWORD_DEFAULT;
const COST = 12;
const TOKEN_SEP = '|';
const USER_COOKIE_PASS = 'checksum';
/**
* Database Connection
* @var MySqlManager
*/
private $oMySql;
private $iUserId;
private $sApiKey;
public function __construct($oMySql, $sApiKey='', $bAutoLogin=true)
{
$this->oMySql = $oMySql;
$this->setUserId(0);
$this->sApiKey = $sApiKey;
if($bAutoLogin) $this->autoLogIn();
}
private function setUserId($iUserId)
{
$this->iUserId = $iUserId;
}
public function getUserId()
{
return $this->iUserId;
}
public function isLoggedIn()
{
return ($this->getUserId() > 0);
}
public function logMeIn($sToken)
{
$sDesc = '';
if($sToken!='')
{
$sLoginToken = addslashes(strstr($sToken, self::TOKEN_SEP, true));
$sPassToken = substr(strstr($sToken, self::TOKEN_SEP), strlen(self::TOKEN_SEP));
if($sLoginToken!='' && $sPassToken!='')
{
$asEmpl = $this->oMySql->selectRow(MyThoughts::USER_TABLE, array("MD5(".MySqlManager::getText(MyThoughts::USER_TABLE).")"=>$sLoginToken));
if(!empty($asEmpl))
{
if(self::CheckPassword($sPassToken, $asEmpl['pass']))
{
$this->setUserId($asEmpl[MySqlManager::getId(MyThoughts::USER_TABLE)]);
$this->resetAuthCookie($this->getUserId());
}
else $sDesc = 'wrong password';
}
else $sDesc = 'unknown nickname';
}
else $sDesc = 'corrupted token, please login again';
}
else $sDesc = 'no credentials has been received by the server';
return MyThoughts::getJsonResult($this->isLoggedIn(), $sDesc);
}
public function autoLogIn()
{
if(isset($_COOKIE[self::USER_COOKIE_PASS]))
{
$sCookie = $_COOKIE[self::USER_COOKIE_PASS];
$iUserId = addslashes(strstr($sCookie, self::TOKEN_SEP, true));
$sCookie = substr(strstr($sCookie, self::TOKEN_SEP), strlen(self::TOKEN_SEP));
$asEmpl = $this->oMySql->selectRow(MyThoughts::USER_TABLE, array(MySqlManager::getId(MyThoughts::USER_TABLE)=>$iUserId));
if(!empty($asEmpl))
{
if($sCookie==$asEmpl['cookie'])
{
$this->setUserId($asEmpl[MySqlManager::getId(MyThoughts::USER_TABLE)]);
//Reset pass once a day
if(mb_substr($asEmpl['led'], 0, 10) != date('Y-m-d')) $this->resetAuthCookie($this->getUserId());
}
else $this->addError('token corrompu pour le user '.$asEmpl[MySqlManager::getId(MyThoughts::USER_TABLE)]);
}
else $this->addError('Utilisateur '.$iUserId.' inconnu');
}
}
public function addUser($sSafeNickName, $sNickName, $bLogMeIn=false)
{
$sPass = self::HashPassword(self::getLoginToken($sSafeNickName));
$bExist = $this->oMySql->pingValue(MyThoughts::USER_TABLE, array(MySqlManager::getText(MyThoughts::USER_TABLE)=>$sSafeNickName));
if($bExist) return -1;
else
{
$iUserId = $this->oMySql->insertRow(MyThoughts::USER_TABLE, array(MySqlManager::getText(MyThoughts::USER_TABLE)=>$sSafeNickName, 'nickname'=>$sNickName));
if($iUserId>0)
{
$this->resetPass($iUserId);
if($bLogMeIn) $this->logMeIn(md5($sSafeNickName).self::TOKEN_SEP.$this->getLoginToken($sSafeNickName));
}
}
return $iUserId;
}
//TODO integrate with logMeIn()
public function checkApiKey($sApiKey)
{
return ($this->sApiKey!='' && $sApiKey==$this->sApiKey);
}
private function resetPass($iUserId=0)
{
$sUserIdCol = MySqlManager::getId(MyThoughts::USER_TABLE);
$sUserTextCol = MySqlManager::getText(MyThoughts::USER_TABLE);
$asInfo = array('select'=>array($sUserIdCol, $sUserTextCol), 'from'=>MyThoughts::USER_TABLE);
if($iUserId>0) $asInfo['constraint'] = array($sUserIdCol=>$iUserId);
$asUsers = $this->oMySql->selectRows($asInfo);
foreach($asUsers as $asUser)
{
$sToken = self::HashPassword(self::getLoginToken($asUser[$sUserTextCol]));
$this->oMySql->updateRow(MyThoughts::USER_TABLE, array(MySqlManager::getId(MyThoughts::USER_TABLE)=>$asUser[$sUserIdCol]), array('pass'=>$sToken));
}
}
private static function getLoginToken($sPass)
{
//Add Server Name
$sServerName = array_key_exists('SERVER_NAME', $_SERVER)?$_SERVER['SERVER_NAME']:$_SERVER['PWD'];
$sAppPath = $_SERVER['REQUEST_SCHEME'].'://'.str_replace(array('http://', 'https://'), '', $sServerName.dirname($_SERVER['SCRIPT_NAME']));
$_GET['serv_name'] = $sAppPath.(mb_substr($sAppPath, -1)!='/'?'/':'');
return md5($sPass.$_GET['serv_name']);
}
private function resetAuthCookie($iUserId)
{
$sNewPass = self::getAuthCookie($iUserId);
$iTimeLimit = time()+60*60*24*30;
//mysqli_query($con, "UPDATE EMPLOYEE SET COOKIE = '".addslashes($sNewPass)."' WHERE ID = ".$iUserId);
$this->oMySql->updateRow(MyThoughts::USER_TABLE, array(MySqlManager::getId(MyThoughts::USER_TABLE)=>$iUserId), array("cookie"=>$sNewPass));
setcookie(self::USER_COOKIE_PASS, $iUserId.self::TOKEN_SEP.$sNewPass, $iTimeLimit);
}
private static function getAuthCookie()
{
return self::HashPassword
(
$_SERVER['HTTP_USER_AGENT'].
$_SERVER['REMOTE_ADDR'].
$_SERVER['REQUEST_TIME'].
mb_strstr(microtime(), ' ', true).
$_SERVER['SERVER_SIGNATURE'].
$_SERVER['SERVER_ADMIN']
);
}
private static function HashPassword($sPass)
{
return password_hash($sPass, self::ALGO, array('cost'=>self::COST));
}
private static function CheckPassword($sPass, $sHash)
{
return password_verify($sPass, $sHash);
}
}
?>
-142
View File
@@ -1,142 +0,0 @@
<?php
class Calendar extends PhpObject
{
const CAL_YEAR = 'cy';
const CAL_MONTH = 'cm';
private $oMySql;
private $oSession;
private $oMask;
private $iUserId;
private $iYear;
private $iMonth;
function __construct($oMySql, $oSession)
{
parent::__construct();
$this->oMySql = $oMySql;
$this->oSession = $oSession;
$this->oMask = new Mask('calendar');
$this->iYear = 0;
$this->iMonth = 0;
}
public function setDate($iYear=0, $iMonth=0)
{
if($iYear==0)
{
$iYear = date('Y');
}
if($iMonth==0)
{
$iMonth = date('m');
}
$this->iYear = $iYear;
$this->iMonth = $iMonth;
}
private function getThoughts()
{
//TODO essayer avec selectRows
$sQuery = "SELECT DATE_FORMAT(led, '%d') AS day
FROM ".MySqlManager::THOUGHTS_TABLE."
WHERE ".MySqlManager::getId(MySqlManager::USERS_TABLE)." = ".$this->oSession->getUserId()."
AND YEAR(led) = ".$this->iYear."
AND MONTH(led) = ".$this->iMonth."
GROUP BY day
ORDER BY day";
return $this->oMySql->getArrayQuery($sQuery, true);
}
private function getUpdatedLink($asParams)
{
$sCurrentVariables = $_SERVER['QUERY_STRING'];
$asCurrentVariables = explode('&', $sCurrentVariables);
foreach($asCurrentVariables as $sParam)
{
$sKey = strstr($sParam, '=', true);
$sValue = substr(strstr($sParam, '='), 1);
$asVariables[$sKey] = $sValue;
}
return '?'.implodeAll(array_merge($asVariables, $asParams), '=', '&');
}
private function getLink($iOffset)
{
$iTimeStamp = mktime(0, 0, 0, $this->iMonth + $iOffset, 1, $this->iYear);
return $this->getUpdatedLink(array(self::CAL_MONTH=>date('n', $iTimeStamp), self::CAL_YEAR=>date('Y', $iTimeStamp)));
}
private function setMaskItems()
{
//week starting on the sunday : offset = 0, monday : offset = 1
$iOffset = 1;
//days in the month
$iMonthLastDay = date('d', mktime(0, 0, 0, $this->iMonth+1, 0, $this->iYear));
$asDays = range(1, $iMonthLastDay);
$iDayNb = 1 - date($iOffset?'N':'w', mktime(0, 0, 0, $this->iMonth, 1, $this->iYear)) + $iOffset;
$iCalendarLastDay = $iMonthLastDay + (7 - date($iOffset?'N':'w', mktime(0, 0, 0, $this->iMonth+1, 0, $this->iYear))) + $iOffset;
//days with thoughts
$asThoughts = $this->getThoughts();
while($iDayNb < $iCalendarLastDay)
{
$iCurrentDayTimeStamp = mktime(0, 0, 0, $this->iMonth, $iDayNb, $this->iYear);
$sItemDate = date('d', $iCurrentDayTimeStamp);
//new week
if(date('w', $iCurrentDayTimeStamp) == $iOffset)
{
$this->oMask->newInstance('WEEK');
}
//day within month
if(date('n', $iCurrentDayTimeStamp)==$this->iMonth)
{
$bThoughts = in_array($iDayNb, $asThoughts);
$sItemClass = $bThoughts?'full':'empty';
$sItemLink = $bThoughts?$this->getUpdatedLink(array('d'=>date(MyThoughts::URL_DATE_FORMAT, $iCurrentDayTimeStamp), 'p'=>'r')):'#';
$sItemLinkTitle = $bThoughts?'See my thoughts on '.date(MyThoughts::LAYOUT_DATE_FORMAT, $iCurrentDayTimeStamp):'';
}
else
{
$sItemClass = 'disabled';
$sItemLink = '#';
$sItemLinkTitle = '';
}
$this->oMask->addInstance('DAY', array('item_day'=>$sItemDate, 'item_class'=>$sItemClass, 'item_link'=>$sItemLink, 'item_link_title'=>$sItemLinkTitle));
$iDayNb++;
}
//column titles
$asDayNames = array('1'=>'Mon', '2'=>'Tue', '3'=>'Wed', '4'=>'Thu', '5'=>'Fri', '6'=>'Sat', $iOffset?'7':'0'=>'Sun');
ksort($asDayNames);
foreach($asDayNames as $sDayName)
{
$this->oMask->addInstance('TITLE', array('day_name'=>$sDayName));
}
}
public function getCalendar()
{
$sResult = '';
if($this->iYear!=0 && $this->iMonth!=0)
{
$this->oMask->setTag('link_prev', $this->getLink(-1));
$this->oMask->setTag('current_month', date('F', mktime(0, 0, 0, $this->iMonth, 1, $this->iYear)));
$this->oMask->setTag('link_next', $this->getLink(1));
$this->setMaskItems();
$sResult = $this->oMask->getMask();
}
return $sResult;
}
}
?>
-342
View File
@@ -1,342 +0,0 @@
<?php
/**
* Main Class
* @author franzz
* @version 2.0
*/
class MyThoughts extends PhpObject
{
//Interface keywords
const SUCCESS = 'success';
const ERROR = 'error';
const UNAUTHORIZED = 'unauthorized';
const NOT_FOUND = 'unknown action';
//SQL tables
const USER_TABLE = 'users';
const THOUGHT_TABLE = 'thoughts';
const SETTINGS_TABLE = 'settings';
//Mythoughts
const URL_DATE_FORMAT = 'Ymd';
const LAYOUT_DATE_FORMAT = 'F \t\h\e jS, Y';
const MYSQL_DATE_FORMAT = 'Y-m-d';
const LAYOUT_TIME_FORMAT = 'G:i';
const WELCOME_MSG_FILE = 'welcome';
const SETTING_LAYOUT = 'layout';
const LAYOUT_ONE_PAGE = '1';
const LAYOUT_TWO_PAGES = '2';
const SETTING_FONT = 'font';
const FONT_THOUGHTS = 'thoughts';
const FONT_ARIAL = 'Arial';
const FONT_VERDANA = 'Verdana';
const SETTING_SIZE = 'Size';
const SIZE_16 = '16';
const SIZE_18 = '18';
const SIZE_20 = '20';
//Objects
private $oClassManagement;
/**
* Database Connection
* @var MySqlManager
*/
private $oMySql;
/**
*
* @var Auth
*/
private $oAuth;
//Variables
private $asContext;
//...
/**
* Main constructor [to be called from index.php]
* @param ClassManagement $oClassManagement
* @param string $sLang
*/
public function __construct($oClassManagement, $sProcessPage)
{
parent::__construct(__CLASS__, Settings::DEBUG);
$this->oClassManagement = $oClassManagement;
$this->setContext($sProcessPage);
//Load classes
$this->oClassManagement->incClass('mysqlmanager');
$this->oClassManagement->incClass('auth', true);
//$this->oClassManagement->incClass('calendar', true);
//Init objects
$this->oMySql = new MySqlManager(Settings::DB_SERVER, Settings::DB_LOGIN, Settings::DB_PASS, Settings::DB_NAME, self::getSqlOptions() , Settings::DB_ENC);
if($this->oMySql->sDbState == MySqlManager::DB_NO_DATA) $this->install();
else $this->oAuth = new Auth($this->oMySql, Settings::API_KEY);
}
private function install()
{
$this->oAuth = new Auth($this->oMySql, Settings::API_KEY, false);
//Install DB
$this->oMySql->install();
$this->addUser('franzz');
}
private function setContext($sProcessPage)
{
//Browser <> PHP <> MySql synchronization
date_default_timezone_set(Settings::TIMEZONE);
ini_set('default_charset', Settings::TEXT_ENC);
header('Content-Type: text/html; charset='.Settings::TEXT_ENC);
mb_internal_encoding(Settings::TEXT_ENC);
mb_http_output(Settings::TEXT_ENC);
mb_http_input(Settings::TEXT_ENC);
mb_language('uni');
mb_regex_encoding(Settings::TEXT_ENC);
$this->asContext['process_page'] = basename($sProcessPage);
$sServerName = array_key_exists('SERVER_NAME', $_SERVER)?$_SERVER['SERVER_NAME']:$_SERVER['PWD'];
$sAppPath = 'http://'.str_replace('http://', '', $sServerName.dirname($_SERVER['SCRIPT_NAME']));
$this->asContext['serv_name'] = $sAppPath.(mb_substr($sAppPath, -1)!='/'?'/':'');
}
public function addUncaughtError($sError)
{
$this->addError('Uncaught errors:'."\n".$sError);
}
/* Authorizations handling */
public function isLoggedIn()
{
return $this->oAuth->isLoggedIn();
}
public function logMeIn($sToken)
{
return $this->oAuth->logMeIn($sToken);
}
public function checkApiKey($sApiKey)
{
return $this->oAuth->checkApiKey($sApiKey);
}
/* Building main pages */
public function getPage($bLoggedIn)
{
/*$asMaskPaths = glob('masks/*.html');
$asMaskNames = array_map('basename', $asMaskPaths, array_fill(1, count($asMaskPaths), '.html'));*/
//Constants
$asPages = array('logon', 'write', 'settings', 'template');
foreach($asPages as $sPage) $asGlobalVars['consts']['pages'][$sPage] = $this->getPageContent($sPage);
$asGlobalVars['consts']['token_sep'] = Auth::TOKEN_SEP;
$asGlobalVars['consts']['error'] = self::ERROR;
$asGlobalVars['consts']['success'] = self::SUCCESS;
$asGlobalVars['consts']['context'] = $this->asContext;
$asGlobalVars['vars']['id'] = $this->oAuth->getUserId();
$asGlobalVars['vars']['log_in'] = $bLoggedIn;
//Main Page
$sPage = $this->getPageContent('index');
$sPage = str_replace('asGlobalVars', json_encode($asGlobalVars), $sPage);
return $sPage;
}
private function getPageContent($sPage)
{
$sPageFile = 'masks/'.$sPage.'.html';
return file_get_contents($sPageFile);
}
/* DB structure. See MySqlManager::__construct */
private static function getSqlOptions()
{
return array
(
'tables' => array
(
self::USER_TABLE =>array(MySqlManager::getText(self::USER_TABLE), 'nickname', 'pass', 'cookie'),
self::THOUGHT_TABLE =>array(MySqlManager::getId(self::USER_TABLE),
MySqlManager::getText(self::THOUGHT_TABLE)),
self::SETTINGS_TABLE=>array(MySqlManager::getId(self::USER_TABLE),
MySqlManager::getText(self::SETTINGS_TABLE),
'value')
),
'types' => array
(
MySqlManager::getText(self::USER_TABLE)=>"varchar(50) NOT NULL",
'nickname'=>'varchar(60) NOT NULL',
'pass'=>"varchar(256) NOT NULL",
'cookie'=>"varchar(255) NOT NULL",
MySqlManager::getText(self::THOUGHT_TABLE)=>"longtext",
MySqlManager::getText(self::SETTINGS_TABLE)=>"varchar(20) NOT NULL",
'value'=>"varchar(20) NOT NULL"
),
'constraints' => array
(
self::USER_TABLE=>"UNIQUE KEY `username` (`".MySqlManager::getText(self::USER_TABLE)."`)"
),
'cascading_delete' => array
(
self::USER_TABLE=>array(self::SETTINGS_TABLE)
)
);
}
/* My Thoughts public functions */
public function register($sNickName)
{
$iUserId = $this->addUser($sNickName, true);
$bSuccess = false;
$sDesc = '';
switch($iUserId)
{
case -1:
$sDesc = 'There is already a user using this nickname, sorry!';
break;
case 0:
$sDesc = 'A database error occured. Contact admin';
break;
default:
$bSuccess = true;
}
return self::getJsonResult($bSuccess, $sDesc);
}
public function updateThought($sThought, $iThoughtId=0)
{
if($iThoughtId==0)
{
$iThoughtId = $this->addThought($sThought);
$sDesc = 'created';
}
else
{
$asKeys = array(MySqlManager::getId(self::USER_TABLE) => $this->oAuth->getUserId(),
MySqlManager::getId(self::THOUGHT_TABLE)=> $iThoughtId);
$asThought = array(MySqlManager::getText(self::THOUGHT_TABLE) => self::encodeThought($sThought));
$iThoughtId = $this->oMySql->updateRow(self::THOUGHT_TABLE, $asKeys, $asThought);
$sDesc = 'updated';
}
$bSuccess = ($iThoughtId>0);
$sDesc = 'thought '.($bSuccess?'':'not ').$sDesc;
return self::getJsonResult($bSuccess, $sDesc, $this->getThoughtInfo($iThoughtId));
}
/* My Thoughts private functions */
private function addUser($sNickName, $bLogMeIn=false)
{
$iUserId = $this->oAuth->addUser(self::getSafeNickName($sNickName), $sNickName, $bLogMeIn);
if($iUserId>0) $this->addThought(file_get_contents(self::WELCOME_MSG_FILE), $iUserId);
return $iUserId;
}
private function addThought($sThought, $iUserId=-1)
{
if($iUserId==-1) $iUserId = $this->oAuth->getUserId();
if($iUserId!=0)
{
$asThought = array( MySqlManager::getId(self::USER_TABLE) => $iUserId,
MySqlManager::getText(self::THOUGHT_TABLE) => self::encodeThought($sThought));
$ithoughtId = $this->oMySql->insertRow(self::THOUGHT_TABLE, $asThought);
}
else $this->addError('Adding a thought with no user id');
return $ithoughtId;
}
private function getThoughtInfo($iThoughtId, $bThoughtContent=false)
{
$asThoughtInfo = array();
if($iThoughtId>0)
{
$asThoughtInfo = $this->oMySql->selectRow(self::THOUGHT_TABLE, $iThoughtId);
if(!$bThoughtContent) unset($asThoughtInfo[MySqlManager::getText(self::THOUGHT_TABLE)]);
}
else $this->addError('getting thought info with no thought id');
return $asThoughtInfo;
}
/* Static toolbox functions */
private static function encodeThought($sthought)
{
return base64_encode(serialize(explode("\n", self::shuffleText($sthought))));
}
private static function decodeThought($sEncodedThought)
{
return self::shuffleText(implode("\n", unserialize(base64_decode($sEncodedThought))));
}
private static function shuffleText($sText)
{
$sRandomText = "let's_mess%a&bit;with~it,!just§for¨the^sake*of-it";
for($iIndex=0; $iIndex < strlen($sText); $iIndex++)
{
$sText[$iIndex] = $sRandomText[$iIndex%strlen($sRandomText)] ^ $sText[$iIndex];
}
return $sText;
}
public static function getJsonResult($bSuccess, $sDesc='', $asVars=array())
{
header('Content-type: application/json');
return json_encode(array('result'=>$bSuccess?self::SUCCESS:self::ERROR, 'desc'=>ToolBox::mb_ucwords($sDesc))+$asVars);
}
public function getSafeNickName($sNickName)
{
return $sNickName;
}
public static function getDateTimeDesc($oTime)
{
$iTimeStamp = is_numeric($oTime)?$oTime:strtotime($oTime);
$sCurTimeStamp = time();
$asWeekDays = array('monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'satursday', 'sunday');
$asMonths = array('january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december');
$sSep = '|';
$sFormat = 'Y'.$sSep.'n'.$sSep.'W'.$sSep.'N'.$sSep.'j'.$sSep.'G';
list($sYear, $sMonth, $sWeek, $sWeekDay, $sDay, $sHour) = explode($sSep, date($sFormat, $iTimeStamp));
list($sCurYear, $sCurMonth, $sCurWeek, $sCurWeekDay, $sCurDay, $sCurHour) = explode($sSep, date($sFormat, $sCurTimeStamp));
$sDesc = '';
if($iTimeStamp>$sCurTimeStamp) $sDesc = 'in the future';
elseif($sCurTimeStamp-$iTimeStamp<60) $sDesc = 'a few seconds ago';
elseif($sCurTimeStamp-$iTimeStamp<60*10) $sDesc = 'a few minutes ago';
elseif($sCurTimeStamp-$iTimeStamp<60*20) $sDesc = '15 minutes ago';
elseif($sCurTimeStamp-$iTimeStamp<60*50) $sDesc = 'half an hour ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*2) $sDesc = 'an hour ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24 && $sDay==$sCurDay) $sDesc = 'at '.$sHour.' o\'clock';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24) $sDesc = 'yesterday';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*7 && $sWeek==$sCurWeek) $sDesc = $asWeekDays[$sWeekDay-1];
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*7) $sDesc = 'last '.$asWeekDays[$sWeekDay-1];
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*9) $sDesc = 'a week ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*12) $sDesc = '10 days ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*16) $sDesc = '2 weeks ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*23) $sDesc = '3 weeks ago';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*31 && $sMonth==$sCurMonth) $sDesc = 'on '.$asMonths[$sMonth-1].', '.$sDay;
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*30*2 && $sMonth==($sCurMonth-1)) $sDesc = 'last month';
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*365 && $sYear==$sCurYear) $sDesc = 'in '.$asMonths[$sMonth-1];
elseif($sCurTimeStamp-$iTimeStamp<60*60*24*365) $sDesc = 'in '.$asMonths[$sMonth-1].' '.$sYear;
elseif($sYear==($sCurYear-1)) $sDesc = 'last year';
else $sDesc = 'in '.$sYear;
//return self::mb_ucfirst($sDesc);
return $sDesc;
}
}
?>
-190
View File
@@ -1,190 +0,0 @@
<?php
/*
MyThoughts Project
http://git.lutran.fr/main.git
Copyright (C) 2015 François Lutran
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see http://www.gnu.org/licenses
*/
/* Requests Handler */
//Start buffering
ob_start();
require_once '../objects/class_management.php';
$oClassManagement = new ClassManagement('mythoughts');
ToolBox::cleanPost($_POST);
ToolBox::cleanPost($_GET);
ToolBox::cleanPost($_REQUEST);
ToolBox::fixGlobalVars(isset($argv)?$argv:array());
//Available variables
$sToken = isset($_GET['token'])?$_GET['token']:'';
$sAction = isset($_REQUEST['a'])?$_REQUEST['a']:'';
$sPage = isset($_GET['p'])?$_GET['p']:'index';
$sNickName = isset($_GET['nickname'])?$_GET['nickname']:'';
$iApiKey = isset($_GET['api'])?$_GET['api']:'';
$sContent = isset($_POST['content'])?$_POST['content']:'';
$iId = isset($_REQUEST['id'])?$_REQUEST['id']:0;
//Initiate class
$oMyThoughts = new MyThoughts($oClassManagement, __FILE__);
$bLoggedIn = $oMyThoughts->isLoggedIn();
$sResult = '';
if($sAction=='logmein') $sResult = $oMyThoughts->logMeIn($sToken);
elseif($sAction!='' && $bLoggedIn)
{
switch ($sAction)
{
case 'update':
$sResult = $oMyThoughts->updateThought($sContent, $iId);
break;
default:
$sResult = MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
}
}
elseif($sAction!='' && !$bLoggedIn)
{
if($oMyThoughts->checkApiKey($iApiKey))
{
switch ($sAction)
{
case '':
//$sResult = $oMyThoughts->apifunction();
break;
default:
$sResult = MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
}
}
elseif($sAction=='register') $sResult = $oMyThoughts->register($sNickName);
else $sResult = MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
}
else $sResult = $oMyThoughts->getPage($bLoggedIn);
$sDebug = ob_get_clean();
if(Settings::DEBUG && $sDebug!='') $oMyThoughts->addUncaughtError($sDebug);
echo $sResult;
/*
//load classes
session_start();
require_once 'config.php';
//clean sent values
cleanPost($_POST);
cleanPost($_GET);
cleanPost($_REQUEST);
//general
$sPage = (isset($_GET['p']) && $_GET['p']!='')?$_GET['p']:'w';
$sPostToken = isset($_POST['post_token'])?$_POST['post_token']:'';
//logon
$sLogin = (isset($_POST['login']) && $_POST['login']!='Nickname')?$_POST['login']:'';
$sPass = (isset($_POST['pass']) && $_POST['pass']!='Password')?$_POST['pass']:'';
$bRegister = (isset($_POST['register']) && $_POST['register']==1);
//writing pad
$sThought = isset($_POST['thoughts'])?$_POST['thoughts']:'';
$iThoughtId = (isset($_POST['thought_id']) && $_POST['thought_id']!='')?$_POST['thought_id']:0; //update or insert
$bFinishedWriting = isset($_POST['finished']);
//calendar
$iDay = isset($_GET['d'])?$_GET['d']:date(MyThoughts::URL_DATE_FORMAT); //d = yyyymmdd
$iCalYear = isset($_GET[Calendar::CAL_YEAR])?$_GET[Calendar::CAL_YEAR]:0; //cy = yyyy
$iCalMonth = isset($_GET[Calendar::CAL_MONTH])?$_GET[Calendar::CAL_MONTH]:0; //cm = m
$oMyThougths = new MyThoughts();
$bValidPost = ($sPostToken!='' && $oMyThougths->checkPostToken($sPostToken));
if($bValidPost)
{
if($bRegister)
{
$oMyThougths->register($sLogin, $sPass);
$sPage = 'r';
}
elseif($sLogin!='' && $sPass!='')
{
$oMyThougths->logMeIn($sLogin, $sPass);
}
}
//if loggued in
if(!$oMyThougths->isLogguedIn())
{
$oMyThougths->logonPage($sLogin);
}
else
{
$oMyThougths->activateMenu();
$oMyThougths->setCalendarDate();
switch($sPage)
{
case 'w': //write a thought
if($bValidPost && $sThought!='' && $sThought!='Talk to me.')
{
if($iThoughtId==0)
{
$iThoughtId = $oMyThougths->addThought($sThought);
}
else
{
$oMyThougths->updateThought($iThoughtId, $sThought);
}
}
if($bFinishedWriting)
{
$oMyThougths->readingPage();
}
else
{
$oMyThougths->writingPage($iThoughtId);
}
break;
case 'r': //read a thought (per day)
if($iDay<=0 || !$oMyThougths->readingPage(strtotime($iDay)))
{
$oMyThougths->writingPage();
}
break;
case 's': // go to settings page
if($bValidPost)
{
$asSettings = array_intersect_key($_POST, array_flip($oMyThougths->getSettingsList()));
$oMyThougths->setSettings($asSettings);
$oMyThougths->writingPage();
}
else
{
$oMyThougths->settingsPage();
}
break;
case 'q': //quit
$oMyThougths->logMeOut();
}
if($iCalYear!=0 && $iCalMonth!=0)
{
$oMyThougths->setCalendarDate($iCalYear, $iCalMonth);
}
}
echo $oMyThougths->getPage();
*/
?>
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace Franzz\MyThoughts;
use Franzz\Objects\PhpObject;
use Franzz\Objects\ToolBox;
/**
* Single entry point: parses the request, guards it, dispatches it.
*/
class Controller extends PhpObject {
//Anything that changes state must arrive as POST with a valid CSRF token.
private const MUTATING_ACTIONS = [
'signup',
'login',
'logout',
'account',
'open_entry',
'save_entry',
'close_entry',
'delete_entry'
];
//Actions that need the session lock held while they run.
private const SESSION_WRITING_ACTIONS = [
'signup',
'login',
'logout'
];
private MyThoughts $oMyThoughts;
private array $asReq = [];
private string $sCsrfToken = '';
public function __construct() {
parent::__construct(__CLASS__);
}
public function handle($sProcessPage, array $argv = []): string {
//Start buffering so warnings/notices can be collected
ob_start();
$asReq = ToolBox::getRequest($argv);
$sAction = $asReq['a'] ?? '';
$this->asReq = [
't' => (string) ($asReq['t'] ?? ''),
'id' => self::positiveInt($asReq['id'] ?? 0),
'dir' => (string) ($asReq['dir'] ?? ''),
'date' => (string) ($asReq['date'] ?? ''),
'content' => (string) ($asReq['content'] ?? ''),
'has_content'=> array_key_exists('content', $asReq),
'name' => (string) ($asReq['name'] ?? ''),
'email' => (string) ($asReq['email'] ?? ''),
'password' => (string) ($asReq['password'] ?? ''),
'remember' => !empty($asReq['remember']),
'field' => (string) ($asReq['field'] ?? ''),
'value' => (string) ($asReq['value'] ?? ''),
//sendBeacon cannot set headers, so the unload close falls back to
//carrying the token in the body.
'csrf_token'=> (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['csrf_token'] ?? ''))
];
//Authentication and CSRF protection share the same server-side session.
$this->initCsrfToken();
$this->oMyThoughts = new MyThoughts($sProcessPage, $this->asReq['t']);
//Validate CSRF, then release the session lock before long-running work.
$bValidMutationRequest = $this->validateMutationRequest($sAction);
if(!$bValidMutationRequest || !in_array($sAction, self::SESSION_WRITING_ACTIONS, true)) $this->closeSession();
if(!$bValidMutationRequest) $sResult = MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
else $sResult = ($sAction == '') ? $this->oMyThoughts->getAppMainPage($this->getCsrfToken()) : $this->dispatch($sAction);
//Clean errors
$sDebug = ob_get_clean();
if($sDebug != '') $this->oMyThoughts->addUncaughtError($sDebug);
$this->closeSession();
return $sResult;
}
private function dispatch(string $sAction): string {
$oJournal = $this->oMyThoughts->getJournal();
return match($sAction) {
/* Account */
'signup' => $this->oMyThoughts->signup($this->asReq['name'], $this->asReq['email'], $this->asReq['password'], $this->asReq['t']),
'login' => $this->oMyThoughts->login($this->asReq['email'], $this->asReq['password'], $this->asReq['t'], $this->asReq['remember']),
'logout' => $this->oMyThoughts->logout(),
'account' => $this->oMyThoughts->updateAccount($this->asReq['field'], $this->asReq['value']),
/* Reading the book */
'book' => $oJournal->getBook(),
'entries' => $oJournal->getEntries($this->asReq['dir'], $this->asReq['id']),
'date' => $oJournal->getEntryIdAtDate($this->asReq['date']),
/* Writing in it */
'open_entry' => $oJournal->openEntry(),
'save_entry' => $oJournal->saveEntry($this->asReq['id'], $this->asReq['content']),
'close_entry' => $oJournal->closeEntry($this->asReq['id'], $this->asReq['content'], $this->asReq['has_content']),
'delete_entry' => $oJournal->deleteEntry($this->asReq['id']),
default => MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND)
};
}
/* CSRF & session */
private function validateMutationRequest(string $sAction): bool {
return
PHP_SAPI === 'cli'
||
!in_array($sAction, self::MUTATING_ACTIONS, true)
||
(($_SERVER['REQUEST_METHOD'] ?? '') === 'POST' && $this->checkCsrfToken($this->asReq['csrf_token']))
;
}
private function getCsrfToken(): string {
if($this->sCsrfToken === '') $this->initCsrfToken();
return $this->sCsrfToken;
}
private function initCsrfToken(): void {
if(PHP_SAPI === 'cli') return;
if(session_status() !== PHP_SESSION_ACTIVE) {
session_set_cookie_params(['httponly' => true, 'secure' => User::isSecureRequest(), 'samesite' => 'Lax']);
session_start();
}
if(empty($_SESSION['csrf_token'])) $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
$this->sCsrfToken = $_SESSION['csrf_token'];
}
private function checkCsrfToken(string $sClientToken): bool {
$sServerToken = $this->getCsrfToken();
return PHP_SAPI === 'cli' || ($sServerToken !== '' && $sClientToken !== '' && hash_equals($sServerToken, $sClientToken));
}
private function closeSession(): void {
if(session_status() === PHP_SESSION_ACTIVE) session_write_close();
}
private static function positiveInt($oValue): int {
return filter_var($oValue, FILTER_VALIDATE_INT, ['options' => ['default' => 0, 'min_range' => 0]]);
}
}
+386
View File
@@ -0,0 +1,386 @@
<?php
namespace Franzz\MyThoughts;
use Franzz\Objects\Db;
use Franzz\Objects\PhpObject;
/**
* The book itself: one continuous, ordered stream of entries per user.
*
* Entries are never split into pages here. Where a page break falls depends on
* the reader's viewport and font size, so pagination is a client-side layout
* concern and the server only ever stores flat text plus the moment it was
* started. Ordering is by id_entry: ids are handed out in writing order, which
* makes them a stable cursor even when two entries share a timestamp.
*/
class Journal extends PhpObject {
public const ENTRY_TABLE = 'entries';
public const STATUS_OPEN = 'open';
public const STATUS_CLOSED = 'closed';
//Entries loaded per request. The book flows, so the client keeps a window
//of entries in memory and extends it when the reader turns past its edge.
public const CHUNK_SIZE = 40;
//An entry left open by a browser that never got to send its close (crash,
//killed tab, lost network) is sealed on the writer's next visit.
private const OPEN_ENTRY_TTL = 60 * 60 * 12;
private const MAX_CONTENT_LENGTH = 262144; //256 KiB of plain text
private Db $oDb;
private User $oUser;
public function __construct(Db &$oDb, User &$oUser) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->oUser = &$oUser;
}
/* Reading */
/**
* Everything the book needs to render on load: the tail of the stream (what
* you were last writing), plus every entry's date for the bookmark rail and
* the calendar. Bookmarks stay cheap - id and timestamp only, no content.
*/
public function getBook(): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$this->sealStaleEntries();
$asEntries = $this->getEntryWindow();
return MyThoughts::getJsonResult(true, '', [
'entries' => $asEntries,
'bookmarks' => $this->getBookmarks(),
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
'has_newer' => false
]);
}
public function getEntries(string $sDirection, int $iCursorId): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$asEntries = match($sDirection) {
'before' => $this->getEntryWindow($iCursorId, 'before'),
'after' => $this->getEntryWindow($iCursorId, 'after'),
'around' => $this->getEntryWindow($iCursorId, 'around'),
default => null
};
if($asEntries === null) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
return MyThoughts::getJsonResult(true, '', [
'entries' => $asEntries,
'has_older' => $this->hasOlderThan($asEntries[0]['id'] ?? 0),
'has_newer' => $this->hasNewerThan(end($asEntries)['id'] ?? 0)
]);
}
/**
* Resolve a calendar click to a cursor: the first entry written on or after
* the given day, falling back to the last entry before it when that day and
* everything after it is blank.
*/
public function getEntryIdAtDate(string $sDate): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$oDate = \DateTime::createFromFormat('Y-m-d', $sDate);
if(!$oDate) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$sDate = $oDate->format('Y-m-d');
$sMidnight = $sDate.' 00:00:00';
$iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' >= '], 'ASC');
if(!$iEntryId) $iEntryId = $this->selectFirstId(['started_on' => $sMidnight], ['started_on' => ' < '], 'DESC');
if(!$iEntryId) return MyThoughts::getJsonResult(false, 'book.no_entry_yet');
return MyThoughts::getJsonResult(true, '', ['id' => (int) $iEntryId]);
}
/* Writing */
/**
* Hand back the entry this session should be writing into: the one still
* open from a reload a minute ago, or a fresh one stamped with now.
*/
public function openEntry(): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
$this->sealStaleEntries();
$iEntryId = (int) $this->selectFirstId(['status' => self::STATUS_OPEN], [], 'DESC');
if($iEntryId <= 0) {
$iEntryId = $this->oDb->insertRow(self::ENTRY_TABLE, [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'content' => '',
'status' => self::STATUS_OPEN,
'started_on' => date(Db::TIMESTAMP_FORMAT),
'closed_on' => MyThoughts::ZERO_TIMESTAMP,
'timezone' => date_default_timezone_get()
]);
if($iEntryId <= 0) return MyThoughts::getJsonResult(false, 'error.commit_db');
}
return MyThoughts::getJsonResult(true, '', ['entry' => $this->getEntryById($iEntryId)]);
}
public function saveEntry(int $iEntryId, string $sContent): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$sContent = self::normaliseContent($sContent);
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, ['content' => $sContent]) === false) {
return MyThoughts::getJsonResult(false, 'error.commit_db');
}
return MyThoughts::getJsonResult(true, '', ['id' => $iEntryId, 'saved_at' => time()]);
}
/**
* Seal an entry: what was written stays as a journal entry, and an entry
* nobody actually wrote in is dropped rather than left as a blank page.
*/
public function closeEntry(int $iEntryId, string $sContent = '', bool $bHasContent = false): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
$asResult = $this->sealEntry($iEntryId, $bHasContent ? $sContent : null);
return MyThoughts::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']);
}
/**
* Shared by the explicit close and by the stale-entry sweep, which runs
* inside other endpoints and so must not emit a response of its own.
*/
private function sealEntry(int $iEntryId, ?string $sContent = null): array {
$asData = ['status' => self::STATUS_CLOSED, 'closed_on' => date(Db::TIMESTAMP_FORMAT)];
//The unload beacon carries the last keystrokes the debounced autosave
//never got to send, so trust it over what is already stored.
if($sContent !== null) $asData['content'] = self::normaliseContent($sContent);
$sStored = $asData['content'] ?? (string) $this->oDb->selectValue(self::ENTRY_TABLE, 'content', $iEntryId);
if(trim($sStored) === '') {
$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId);
return MyThoughts::getResult(true, '', ['id' => $iEntryId, 'discarded' => true]);
}
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, $asData) === false) {
return MyThoughts::getResult(false, 'error.commit_db');
}
return MyThoughts::getResult(true, '', ['entry' => $this->getEntryById($iEntryId), 'discarded' => false]);
}
public function deleteEntry(int $iEntryId): string {
if(!$this->oUser->isLoggedIn()) return MyThoughts::getJsonResult(false, MyThoughts::UNAUTHORIZED);
if(!$this->ownsEntry($iEntryId)) return MyThoughts::getJsonResult(false, MyThoughts::NOT_FOUND);
if(!$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId)) return MyThoughts::getJsonResult(false, 'error.commit_db');
return MyThoughts::getJsonResult(true, 'book.entry_deleted', ['id' => $iEntryId]);
}
/* Internals */
/**
* @param int $iCursorId 0 for the newest window
* @param string $sDirection before|after|around, relative to the cursor
*/
private function getEntryWindow(int $iCursorId = 0, string $sDirection = 'latest'): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
//"around" is two half-windows so the target entry lands mid-book with
//something to read on either side of it.
if($sDirection == 'around' && $iCursorId > 0) {
$asBefore = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' < '], 'DESC', (int) (self::CHUNK_SIZE / 2));
$asFrom = $this->selectEntries([$sIdColumn => $iCursorId], [$sIdColumn => ' >= '], 'ASC', (int) (self::CHUNK_SIZE / 2));
return array_merge(array_reverse($asBefore), $asFrom);
}
//selectEntries() scopes every read to the logged-in user, so the window
//itself only has to say where in the stream it starts.
$asConstraints = [];
$asOperators = [];
if($iCursorId > 0 && $sDirection == 'before') {
$asConstraints[$sIdColumn] = $iCursorId;
$asOperators[$sIdColumn] = ' < ';
}
elseif($iCursorId > 0 && $sDirection == 'after') {
$asConstraints[$sIdColumn] = $iCursorId;
$asOperators[$sIdColumn] = ' > ';
}
//"after" reads forward; everything else reads backward from the cursor
//and is flipped, so the caller always gets chronological order.
$bForward = ($sDirection == 'after');
$asEntries = $this->selectEntries($asConstraints, $asOperators, $bForward ? 'ASC' : 'DESC', self::CHUNK_SIZE);
return $bForward ? $asEntries : array_reverse($asEntries);
}
private function selectEntries(array $asConstraints, array $asOperators, string $sOrder, int $iLimit): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$asRows = $this->oDb->selectRows([
'select' => [$sIdColumn.' AS id', 'content', 'status', 'timezone', 'UNIX_TIMESTAMP(started_on) AS time', 'UNIX_TIMESTAMP(closed_on) AS closed'],
'from' => self::ENTRY_TABLE,
'constraint'=> $asConstraints,
'constOpe' => $asOperators,
'orderBy' => [$sIdColumn => $sOrder],
'limit' => $iLimit
]);
return array_map([self::class, 'castEntry'], $asRows);
}
private function getEntryById(int $iEntryId): array {
$asEntries = $this->selectEntries([Db::getId(self::ENTRY_TABLE) => $iEntryId], [], 'ASC', 1);
return $asEntries[0] ?? [];
}
/**
* The bookmark rail and the calendar both need every entry's date, and
* nothing else - so this deliberately never selects content.
*/
private function getBookmarks(): array {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asRows = $this->oDb->selectRows([
'select' => [
$sIdColumn.' AS id',
'UNIX_TIMESTAMP(started_on) AS time',
'timezone',
'status',
'LEFT(content, 60) AS preview'
],
'from' => self::ENTRY_TABLE,
'constraint'=> [Db::getId(User::USER_TABLE) => $this->oUser->getUserId()],
'orderBy' => [$sIdColumn => 'ASC']
]);
return array_map(static function(array $asRow): array {
return [
'id' => (int) $asRow['id'],
'time' => (int) $asRow['time'],
'timezone' => $asRow['timezone'],
'status' => $asRow['status'],
'preview' => trim(preg_replace('/\s+/u', ' ', $asRow['preview'] ?? ''))
];
}, $asRows);
}
private function hasOlderThan(int $iEntryId): bool {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' < '], 'DESC') !== false);
}
private function hasNewerThan(int $iEntryId): bool {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
return ($iEntryId > 0) && ($this->selectFirstId([$sIdColumn => $iEntryId], [$sIdColumn => ' > '], 'ASC') !== false);
}
/**
* @return int|false Id of the first matching entry in the given order
*/
private function selectFirstId(array $asConstraints, array $asOperators, string $sOrder) {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
$asConstraints[Db::getId(User::USER_TABLE)] = $this->oUser->getUserId();
$asRows = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> $asConstraints,
'constOpe' => $asOperators,
'orderBy' => [$sIdColumn => $sOrder],
'limit' => 1
]);
return empty($asRows) ? false : (int) $asRows[0];
}
private function ownsEntry(int $iEntryId): bool {
if($iEntryId <= 0) return false;
$iOwnerId = $this->oDb->selectValue(
self::ENTRY_TABLE,
Db::getId(User::USER_TABLE),
[Db::getId(self::ENTRY_TABLE) => $iEntryId]
);
return ($iOwnerId !== false) && ((int) $iOwnerId === $this->oUser->getUserId());
}
/**
* Close anything the browser abandoned. Without this a stale open entry
* would silently swallow tomorrow's writing into yesterday's timestamp.
*/
private function sealStaleEntries(): void {
$sIdColumn = Db::getId(self::ENTRY_TABLE);
//Db::updateRows only builds equality constraints, so the cutoff has to
//be resolved to a list of ids first.
$asStale = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'status' => self::STATUS_OPEN,
'led' => date(Db::TIMESTAMP_FORMAT, time() - self::OPEN_ENTRY_TTL)
],
'constOpe' => ['led' => ' < ']
]);
foreach($asStale as $iStaleId) $this->sealEntry((int) $iStaleId);
//Blank pages left behind by earlier sessions - nothing was written, so
//they are not entries and should not show up as bookmarks.
$asBlank = $this->oDb->selectRows([
'select' => [$sIdColumn],
'from' => self::ENTRY_TABLE,
'constraint'=> [
Db::getId(User::USER_TABLE) => $this->oUser->getUserId(),
'status' => self::STATUS_CLOSED,
'TRIM(content)' => ''
]
]);
foreach($asBlank as $iBlankId) $this->oDb->deleteRow(self::ENTRY_TABLE, (int) $iBlankId);
}
private static function castEntry(array $asRow): array {
return [
'id' => (int) $asRow['id'],
'content' => (string) $asRow['content'],
'status' => $asRow['status'],
'timezone' => $asRow['timezone'],
'time' => (int) $asRow['time'],
'closed' => (int) $asRow['closed']
];
}
/**
* Plain text in, plain text out: normalise line endings, strip control
* characters the book can never render, and cap the size.
*
* Tab and newline are the two the page does render - the text column sets a
* tab-size and the paginator breaks paragraphs on newlines - so they are
* held back from the sweep. Everything else in \p{C} (other controls, zero
* width joiners, bidi overrides) would either draw nothing or quietly
* corrupt the character offsets the caret is placed with.
*/
private static function normaliseContent(string $sContent): string {
$sContent = str_replace(["\r\n", "\r"], "\n", $sContent);
$sContent = preg_replace('/[^\P{C}\n\t]+/u', '', $sContent) ?? '';
return mb_substr($sContent, 0, self::MAX_CONTENT_LENGTH);
}
}
+201
View File
@@ -0,0 +1,201 @@
<?php
namespace Franzz\MyThoughts;
use Franzz\Objects\Db;
use Franzz\Objects\Main;
use Franzz\Objects\Translator;
use Settings;
/* Timezones
* ---------
* Every request carries the browser's IANA timezone in `t`, which Main applies
* to PHP and to the MySQL session. Timestamps therefore go in and come out in
* the reader's own time. On top of that each entry stores the timezone it was
* written in, so a journal written across a move or a trip still shows each
* page stamped with the local time of the moment it was written - and the
* client formats from a UNIX timestamp, never from a preformatted string.
*/
class MyThoughts extends Main {
public const PROJECT_NAME = 'MyThoughts';
public const DEFAULT_LANG = 'en';
//The dictionaries shipped in resources/lang. Listed rather than globbed:
//Translator resolves its folder relative to the calling script, and the
//settings panel needs the list on a page load either way.
public const LANGUAGES = ['en', 'fr'];
/* "Never happened", for the TIMESTAMP columns declared DEFAULT 0.
*
* Not date(TIMESTAMP_FORMAT, 0): that formats the epoch in the session's
* timezone, which east of Greenwich lands before 1970-01-01 00:00:01 UTC -
* below what a MySQL TIMESTAMP can hold - and is rejected outright under
* STRICT_TRANS_TABLES. This is the same literal the columns default to. */
public const ZERO_TIMESTAMP = '0000-00-00 00:00:00';
private const MAIN_PAGE = 'index';
private const VITE_APP = 'src/app.js';
private User $oUser;
private Journal $oJournal;
public function __construct($sProcessPage, $sTimezone) {
parent::__construct($sProcessPage, true, $sTimezone);
$this->oUser = new User($this->oDb);
$this->oLang = new Translator($this->oUser->getLang(), self::DEFAULT_LANG);
$this->oJournal = new Journal($this->oDb, $this->oUser);
}
public function getUser(): User {
return $this->oUser;
}
public function getJournal(): Journal {
return $this->oJournal;
}
protected function install() {
$this->oDb->install();
}
protected function getSqlOptions() {
return [
'tables' => [
User::USER_TABLE => ['name', 'email', 'password', 'token', 'token_exp', 'language', 'timezone', 'clearance'],
Journal::ENTRY_TABLE=> [Db::getId(User::USER_TABLE), 'content', 'status', 'started_on', 'closed_on', 'timezone']
],
'types' => [
'name' => 'VARCHAR(100) NOT NULL',
'email' => 'VARCHAR(320) NOT NULL',
'password' => "VARCHAR(255) NOT NULL DEFAULT ''",
'token' => "VARCHAR(64) NOT NULL DEFAULT ''",
'token_exp' => 'TIMESTAMP DEFAULT 0',
'language' => 'VARCHAR(2)',
'timezone' => 'CHAR(64) NOT NULL', //see mysql.time_zone_name
'clearance' => 'TINYINT(1) DEFAULT '.User::CLEARANCE_USER,
'content' => 'LONGTEXT',
'status' => 'VARCHAR(10)',
'started_on'=> 'TIMESTAMP DEFAULT 0', //DEFAULT 0 removes auto-set to current time
'closed_on' => 'TIMESTAMP DEFAULT 0'
],
'constraints' => [
User::USER_TABLE => 'UNIQUE KEY `uni_email` (`email`)',
//The book is always read as "this user's entries, in order",
//so the reading order is indexed rather than the id alone.
Journal::ENTRY_TABLE=> ['INDEX `idx_user_entry` (`id_user`, `id_entry`)', 'INDEX `idx_user_date` (`id_user`, `started_on`)']
],
//Deliberately no 'cascading_delete': Db cascades by reusing the same
//id in the linked table, which would delete unrelated rows here.
//The generated foreign keys already guard referential integrity.
];
}
/* Pages & API */
public function getAppMainPage(string $sCsrfToken = ''): string {
$asViteAssets = $this->getViteAssets();
return parent::getMainPage(
[
'user' => $this->oUser->getUserInfo(),
'consts' => [
'title' => self::PROJECT_NAME,
'languages' => self::LANGUAGES,
'chunk_size' => Journal::CHUNK_SIZE,
'default_timezone' => Settings::TIMEZONE,
'autosave_delay' => 1200, //ms of stillness before a save
'autosave_max_wait' => 8000, //ms of continuous typing before a forced save
'csrf_token' => $sCsrfToken
]
],
self::MAIN_PAGE,
[
'tags' => [
'language' => $this->oLang->getLanguage(),
'title' => self::PROJECT_NAME,
'app_entry' => $asViteAssets['app']
],
'instances' => [
'css' => $asViteAssets['css'],
'module' => $asViteAssets['module']
]
]
);
}
public function signup(string $sName, string $sEmail, string $sPassword, string $sTimezone): string {
$asResult = $this->oUser->signup($sName, $sEmail, $sPassword, $this->oLang->getLanguage(), $sTimezone);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
}
public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): string {
$asResult = $this->oUser->login($sEmail, $sPassword, $sTimezone, $bRemember);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
}
public function logout(): string {
$asResult = $this->oUser->logout();
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data']);
}
public function updateAccount(string $sField, string $sValue): string {
$asResult = $this->oUser->updateSettings($sField, $sValue);
//A language change has to reach the next page load's translations too.
if($asResult['result'] && $sField == 'language') $this->oLang->setLanguage($this->oUser->getLang(), self::DEFAULT_LANG);
return self::getJsonResult($asResult['result'], $asResult['desc_lang_id'], $asResult['data'], $asResult['desc_lang_params']);
}
/* Vite assets */
private function getViteAssets(): array {
$sManifestPath = __DIR__.'/../public/.vite/manifest.json';
if(!file_exists($sManifestPath)) {
$this->addError('Vite manifest not found - run "npm run dev" or "npm run prod" to build the frontend.');
return ['app' => '', 'css' => [], 'module' => []];
}
$asManifest = json_decode(file_get_contents($sManifestPath), true);
$asAppImport = $asManifest[self::VITE_APP] ?? [];
//Recursive search for chunk imports
$asImports = [];
$asSeenImports = [self::VITE_APP => true];
$this->appendViteImportedChunks($asManifest, $asAppImport, $asSeenImports, $asImports);
//CSS
$asCssFiles = [];
foreach(array_merge([$asAppImport], $asImports) as $asChunk) {
foreach($asChunk['css'] ?? [] as $sCssFile) $asCssFiles[] = $sCssFile;
}
//Modules
$asModuleFiles = [];
foreach($asImports as $asImport) {
if(str_ends_with($asImport['file'] ?? '', '.js')) $asModuleFiles[] = $asImport['file'];
}
return [
'app' => $asAppImport['file'] ?? '',
'css' => self::getViteAssetInstances($asCssFiles),
'module' => self::getViteAssetInstances($asModuleFiles)
];
}
private function appendViteImportedChunks($asManifest, $asChunk, &$asSeenImports, &$asImports): void {
foreach($asChunk['imports'] ?? [] as $sImport) {
if(isset($asSeenImports[$sImport]) || !isset($asManifest[$sImport])) continue;
$asSeenImports[$sImport] = true;
$this->appendViteImportedChunks($asManifest, $asManifest[$sImport], $asSeenImports, $asImports);
$asImports[] = $asManifest[$sImport];
}
}
private static function getViteAssetInstances(array $asFilePaths): array {
return array_map(static function($sFilePath) { return ['filename' => $sFilePath]; }, $asFilePaths);
}
}
+277
View File
@@ -0,0 +1,277 @@
<?php
namespace Franzz\MyThoughts;
use Franzz\Objects\Db;
use Franzz\Objects\PhpObject;
/**
* Accounts, sessions and the remember-me cookie.
*
* Every reader gets their own book: an entry always belongs to exactly one
* user, and nothing in Journal is reachable without a resolved user id.
*/
class User extends PhpObject {
public const USER_TABLE = 'users';
public const CLEARANCE_USER = 0;
public const CLEARANCE_ADMIN = 9;
private const MIN_PASSWORD_LENGTH = 8;
private const MAX_NAME_LENGTH = 100;
public const DEFAULT_USER = [
'id' => 0,
'name' => '',
'email' => '',
'language' => '',
'timezone' => '',
'clearance' => self::CLEARANCE_USER
];
//Session & Cookie
private const SESSION_ID_USER = 'id_user';
private const COOKIE_TOKEN = 'mythoughts';
private const COOKIE_DURATION = 60 * 60 * 24 * 90; //3 months
private Db $oDb;
private int $iUserId = 0;
private array $asUserInfo = self::DEFAULT_USER;
public function __construct(Db &$oDb) {
parent::__construct(__CLASS__);
$this->oDb = &$oDb;
$this->setUserId(0);
$this->checkSession();
}
/* Identity */
public function getUserId(): int {
return $this->iUserId;
}
public function isLoggedIn(): bool {
return ($this->iUserId > 0);
}
public function getUserInfo(): array {
return $this->asUserInfo;
}
public function getLang(): string {
return $this->asUserInfo['language'];
}
public function getTimezone(): string {
return $this->asUserInfo['timezone'];
}
public function setUserId($iUserId): void {
$this->iUserId = 0;
$this->asUserInfo = self::DEFAULT_USER;
if($iUserId > 0) {
$asUser = $this->getUserById($iUserId);
if(!empty($asUser)) {
$this->iUserId = (int) $iUserId;
$this->asUserInfo = $asUser;
}
}
}
private function getUserById($iUserId): array {
if($iUserId <= 0) return [];
$asSelect = array_keys(self::DEFAULT_USER);
$asSelect[array_search('id', $asSelect)] = Db::getId(self::USER_TABLE).' AS id';
$asUser = $this->oDb->selectRow(self::USER_TABLE, [Db::getId(self::USER_TABLE) => $iUserId], $asSelect);
if(empty($asUser)) return [];
$asUser['id'] = (int) $asUser['id'];
$asUser['clearance'] = (int) $asUser['clearance'];
return $asUser;
}
/* Sign up & log in */
public function signup(string $sName, string $sEmail, string $sPassword, string $sLang, string $sTimezone): array {
$sEmail = mb_strtolower(trim($sEmail));
$sName = mb_substr(trim($sName), 0, self::MAX_NAME_LENGTH);
if($sName === '') return MyThoughts::getResult(false, 'account.name_required');
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) return MyThoughts::getResult(false, 'account.invalid_email');
if(mb_strlen($sPassword) < self::MIN_PASSWORD_LENGTH) return MyThoughts::getResult(false, 'account.password_too_short', [], [self::MIN_PASSWORD_LENGTH]);
//Taken emails must not be distinguishable from a wrong password, so the
//message stays the same one login gives - no account enumeration here.
if($this->oDb->selectId(self::USER_TABLE, ['email' => $sEmail]) > 0) {
return MyThoughts::getResult(false, 'account.invalid_credentials');
}
$iUserId = $this->oDb->insertRow(self::USER_TABLE, [
'name' => $sName,
'email' => $sEmail,
'password' => password_hash($sPassword, PASSWORD_DEFAULT),
'language' => $sLang,
'timezone' => $sTimezone,
'clearance' => self::CLEARANCE_USER
]);
if($iUserId <= 0) return MyThoughts::getResult(false, 'error.commit_db');
$this->openSessionFor($iUserId, true);
return MyThoughts::getResult(true, 'account.welcome', ['user' => $this->getUserInfo()]);
}
public function login(string $sEmail, string $sPassword, string $sTimezone, bool $bRemember): array {
$sEmail = mb_strtolower(trim($sEmail));
$asDbUser = $this->oDb->selectRow(
self::USER_TABLE,
['email' => $sEmail],
[Db::getId(self::USER_TABLE), 'password', 'timezone']
);
$iUserId = (int) ($asDbUser[Db::getId(self::USER_TABLE)] ?? 0);
//password_verify against a dummy hash on unknown emails keeps the
//response time of "no such user" and "wrong password" comparable.
$sHash = $asDbUser['password'] ?? '';
$bValid = ($sHash !== '') ? password_verify($sPassword, $sHash) : password_verify($sPassword, '$2y$10$'.str_repeat('.', 53));
if($iUserId <= 0 || !$bValid) return MyThoughts::getResult(false, 'account.invalid_credentials');
if($sTimezone !== '' && $sTimezone !== ($asDbUser['timezone'] ?? '')) {
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['timezone' => $sTimezone]);
}
$this->openSessionFor($iUserId, $bRemember);
return MyThoughts::getResult(true, 'account.logged_in', ['user' => $this->getUserInfo()]);
}
public function logout(): array {
$this->clearTokenCookie();
$_SESSION = [];
if(session_status() === PHP_SESSION_ACTIVE) session_regenerate_id(true);
$this->setUserId(0);
return MyThoughts::getResult(true, 'account.logged_out');
}
public function updateSettings(string $sField, string $sValue): array {
if(!$this->isLoggedIn()) return MyThoughts::getResult(false, MyThoughts::UNAUTHORIZED);
if(!in_array($sField, ['name', 'language', 'timezone'], true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
$sValue = mb_substr(trim($sValue), 0, self::MAX_NAME_LENGTH);
if($sField === 'name' && $sValue === '') return MyThoughts::getResult(false, 'account.name_required');
if($sField === 'timezone' && !in_array($sValue, \DateTimeZone::listIdentifiers(), true)) return MyThoughts::getResult(false, MyThoughts::NOT_FOUND);
if(!$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [$sField => $sValue])) {
return MyThoughts::getResult(false, 'error.commit_db');
}
$this->setUserId($this->iUserId);
return MyThoughts::getResult(true, 'account.saved', ['user' => $this->getUserInfo()]);
}
/* Session plumbing */
private function openSessionFor(int $iUserId, bool $bRemember): void {
$this->setUserId($iUserId);
if(session_status() === PHP_SESSION_ACTIVE) {
session_regenerate_id(true);
$_SESSION[self::SESSION_ID_USER] = $iUserId;
}
if($bRemember) $this->setTokenCookie();
else $this->clearTokenCookie();
}
private function checkSession(): void {
$iUserId = (int) ($_SESSION[self::SESSION_ID_USER] ?? 0);
if($iUserId > 0) $this->setUserId($iUserId);
else $this->checkTokenCookie();
}
/**
* Cookie holds "<id_user>:<secret>"; only a hash of the secret is stored,
* so a dump of the users table cannot be replayed as a login.
*/
private function checkTokenCookie(): void {
$sCookie = $_COOKIE[self::COOKIE_TOKEN] ?? '';
if($sCookie === '') return;
$asParts = explode(':', $sCookie, 2);
if(count($asParts) != 2) return;
$iUserId = (int) $asParts[0];
$sSecret = $asParts[1];
if($iUserId <= 0 || $sSecret === '') return;
$asToken = $this->oDb->selectRow(self::USER_TABLE, $iUserId, ['token', 'token_exp']);
$sStoredHash = $asToken['token'] ?? '';
if($sStoredHash === '' || strtotime($asToken['token_exp'] ?? '0') < time()) {
$this->clearTokenCookie();
return;
}
if(!hash_equals($sStoredHash, hash('sha256', $sSecret))) {
$this->clearTokenCookie();
return;
}
$this->setUserId($iUserId);
if(!$this->isLoggedIn()) {
$this->clearTokenCookie();
return;
}
if(session_status() === PHP_SESSION_ACTIVE) $_SESSION[self::SESSION_ID_USER] = $iUserId;
//Sliding expiry: an active reader is never logged out mid-journal.
$this->setTokenCookie();
}
private function setTokenCookie(): void {
if(!$this->isLoggedIn()) return;
$sSecret = bin2hex(random_bytes(32));
$iExpiry = time() + self::COOKIE_DURATION;
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [
'token' => hash('sha256', $sSecret),
'token_exp' => date(Db::TIMESTAMP_FORMAT, $iExpiry)
]);
$this->writeCookie($this->iUserId.':'.$sSecret, $iExpiry);
}
private function clearTokenCookie(): void {
if($this->isLoggedIn()) {
$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, ['token' => '', 'token_exp' => MyThoughts::ZERO_TIMESTAMP]);
}
$this->writeCookie('', time() - 3600);
unset($_COOKIE[self::COOKIE_TOKEN]);
}
private function writeCookie(string $sValue, int $iExpiry): void {
if(PHP_SAPI === 'cli' || headers_sent()) return;
setcookie(self::COOKIE_TOKEN, $sValue, [
'expires' => $iExpiry,
'path' => dirname($_SERVER['SCRIPT_NAME'] ?? '/'),
'httponly' => true,
'secure' => self::isSecureRequest(),
'samesite' => 'Lax'
]);
}
public static function isSecureRequest(): bool {
return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
}
}
View File
-27
View File
@@ -1,27 +0,0 @@
<div id="calendar">
<table class="calendar_list">
<thead>
<tr>
<th colspan="7">
<a class="calendar_direction" href="#link_prev#">&lt;-</a>&nbsp;
#current_month#
<a class="calendar_direction" href="#link_next#">-&gt;</a>&nbsp;
</th>
</tr>
</thead>
<tbody class="round">
<tr>
<!-- [PART] TITLE [START] -->
<td>#day_name#</td>
<!-- [PART] TITLE [END] -->
</tr>
<!-- [PART] WEEK [START] -->
<tr class="calendar_items">
<!-- [PART] DAY [START] -->
<td class="item_#item_class#" title="#item_link_title#" onclick="goTo('#item_link#');">#item_day#</td>
<!-- [PART] DAY [END] -->
</tr>
<!-- [PART] WEEK [END] -->
</tbody>
</table>
</div>
-7
View File
@@ -1,7 +0,0 @@
<div id="errors" class="round_top">
<ul>
<!-- [PART] ERROR [START] -->
<li>#error#</li>
<!-- [PART] ERROR [END] -->
</ul>
</div>
-6
View File
@@ -1,6 +0,0 @@
<script type="text/javascript">
oMyThoughts.pageInit = function(asHash, bFirstPage)
{
console.log('home init');
}
</script>
-25
View File
@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="author" content="Franzz" />
<link href="style/style.css" rel="stylesheet" type="text/css" />
<link href="style/font-awesome.css" rel="stylesheet" type="text/css" />
<link href="style/trumbowyg.min.css" rel="stylesheet" type="text/css" />
<link href="style/jquery-te-1.4.0.css" rel="stylesheet" type="text/css" />
<script type="text/javascript" src="scripts/jquery.js"></script>
<script type="text/javascript" src="scripts/functions.js"></script>
<script type="text/javascript" src="scripts/mythoughts.js"></script>
<script type="text/javascript" src="scripts/trumbowyg.min.js"></script>
<script type="text/javascript" src="scripts/jquery-te-1.4.0.min.js"></script>
<link rel="shortcut icon" href="images/favicon2.ico" />
<title>My Thoughts</title>
</head>
<body>
<div id="container"></div>
<script type="text/javascript">
var oMyThoughts = new MyThoughts(asGlobalVars);
$(document).ready(oMyThoughts.init);
</script>
</body>
</html>
-56
View File
@@ -1,56 +0,0 @@
<div id="logon">
<form name="post_logon" id="post_logon">
<div class="credentials">
<p><input type="text" name="login" id="login" value="" /></p>
<p><input type="password" name="pass" id="pass" /></p>
</div>
<p class="register"><input type="button" name="register" id="register" value="No account?" /></p>
<p><input type="button" name="ok" id="ok" class="connection" value="Ok" /></p>
</form>
</div>
<script type="text/javascript">
oMyThoughts.pageInit = function(asHash, bFirstPage)
{
$('#logon').hide();
$('#login').addDefaultValue('Nickname');
$('#pass').addDefaultValue('Password');
$(window).keyup(function(e){if(e.which==13) logMeIn();});
$('#ok').click(logMeIn);
$('#register').click(register);
$('#logon').fadeIn('slow');
};
function logMeIn()
{
var sLogin = $.trim(removeDiacritics($('#login').val().toLowerCase()));
var sPass = $.trim($('#pass').val());
if($('#post_logon').checkForm())
{
getInfo
(
'logmein',
oMyThoughts.loadHome,
{token:md5(sLogin)+oMyThoughts.consts.token_sep+getLoginToken(sPass)},
function(sDesc){feedback('error', sDesc);}
);
}
else feedback('warning', 'incomplete form');
}
function register()
{
if($('#post_logon').checkForm('#login'))
{
getInfo
(
'register',
oMyThoughts.loadHome,
{nickname:$('#login').val()},
function(sDesc){feedback('error', sDesc);}
);
}
else feedback('warning', 'Please choose a nickname');
}
</script>
-4
View File
@@ -1,4 +0,0 @@
<a id="signout" href="?p=w" title="Exit" class="option">Write</a>&nbsp;.&nbsp;
<a id="signout" href="?p=s" title="Exit" class="option">Settings</a>&nbsp;.&nbsp;
<a id="signout" href="?p=q" title="Exit" class="option">Sign out</a>
#calendar#
-14
View File
@@ -1,14 +0,0 @@
<p class="date">Thoughts on #date#.</p>
<div class="read round_right">
<!-- [PART] THOUGHT [START] -->
<div class="thought">
<div class="time">At #time#</div>
<div class="paragraphs">
<!-- [PART] THOUGHT_PARA [START] -->
<p>#thought_paragraph#</p>
<!-- [PART] THOUGHT_PARA [END] -->
<p style="text-align:center;text-indent:0;font-family:Comic sans MS;">*&nbsp;*&nbsp;*</p>
</div>
</div>
<!-- [PART] THOUGHT [END] -->
</div>
-19
View File
@@ -1,19 +0,0 @@
<div id="settings">
<form method="post" action="?p=s" name="post_settings">
<table>
<!-- [PART] SETTING [START] -->
<tr>
<td>#setting_name#</td>
<td>
<select name="#setting_name#">
<!-- [PART] SETTING_OPTION [START] -->
<option value="#setting_option_value#" #setting_option_selected#>#setting_option_name#</option>
<!-- [PART] SETTING_OPTION [END] -->
</select>
</td>
</tr>
<!-- [PART] SETTING [END] -->
</table>
<input type="submit" value="Ok" />
</form>
</div>
-11
View File
@@ -1,11 +0,0 @@
<div id="feedback"></div>
<div id="header"></div>
<div id="menu">
<a href="#settings" class="button fa fa-gear"></a>
</div>
<div id="main"></div>
<div id="footer">
Designed and powered by Franzz &amp; Clarita.
My Thoughts Project under <a href="http://www.gnu.org/licenses/gpl.html" target="_blank">GPLv3</a> License.
</div>
#errors#
-176
View File
@@ -1,176 +0,0 @@
<div id="write">
<div id="write_feedback"></div>
<div id="nav">
<a class="fa fa-prev"></a>
<span class="page_nb">1</span>
<A class="fa fa-next"></a>
</div>
<textarea id="editor"></textarea>
</div>
<script type="text/javascript">
var sText = //'<p>David na pas fait grand chose, il a juste créé un embryon de programme. Mais ce programme sest développé lui-même. Comme lordinateur de David n’était pas suffisant, il a utilisé le réseau pour sinstaller sur les autres ordinateurs. Il a grandi alors de manière exponentielle et le voilà : Prélude. Connecté à tout les ordinateurs et capable de leur donner les ordres quil veut.</p>'
//+'<p>Il sort de son lit, les yeux dans un brouillard Londonien, avance jusqu\'à la salle de bain dont la baignoire a été remplie cinq minutes avant par l\'ordinateur de la maison, et va directement prendre un bain. Un bain moussant comme tout les matins. Un bain bien chaud. Et comme il est trop grand pour sa baignoire, ses pieds dépassent. Quelques minutes plus tard, il sendort. Aucun risque de noyade.</p>'
//+'<p>« Prélude mavait dit quil désirait connaître lamour. Les ordinateurs nont pas de sentiments et lamour nest que sentiments. Il y a bien lamour physique, mais sans les sentiments, cela ressemble davantage à un instinct de reproduction qu’à de lamour. Un ordinateur na pas ce besoin de reproduction. Et pourquoi mavoir choisi ? »</p>'
//+'<p>« Oui, mais rien dexceptionnel. » David essai de se rappeler si dans la lancé de sa jeunesse fougueuse, il naurait pas installé une bombe logique sur les ordinateurs de larmée, mais il ne se rappelait pas avoir fait une telle bêtise. Planter tout le système informatique de la base aurait été trop grave de conséquences.</p>'
'<p>So, I\'m there, wondering about myself and my future - such a luxury by the way - when suddenly, someone approaches. mid-40s i\'d say, elegant cold women, the kind you\'ve learned not to talk to, to eventually avoid feeling repressed, or bad for yourself, or both.</p>'
+'<p>Anyhow, it would seem that in this particular situation, the choice wasn\'t given to me to decide whether or not to talk to that precise women. She seats down next to me and engage a conversation. \'How is it you\'re here today?\' I\'m already regretting not having gone away when i still had the chance.</p>'
+'<p>\'Hello to you too\' I say, still willing to show some manhood, even though I already know this women crushes manhood by pairs as a breakfast ritual.'
+'<p>In-extremist, I manage to babble some excuses about a rigorous lunch break time and leave the premises.</p>';
oMyThoughts.pageInit = function(asHash, bFirstPage)
{
self.vars('counter', 0);
self.vars('id', 0);
self.vars('default_text', '<p><br></p>');
self.vars('working', false);
self.vars('prec_displayed', 0);
self.vars('prec_content', 0);
self.vars('page', $('.page_nb').text());
$('#editor')
.jqte({
br: false,
center: false,
color: false,
fsize: false,
format: false,
indent: false,
link: false,
left: false,
outdent: false,
placeholder: "It all started here",
remove:false,
right:false,
rule:false,
source:false,
sub:false,
sup:false,
title:false,
unlink:false,
change: function(){
//First run
if(!self.vars('editor'))
{
self.vars('max_height', $('.jqte').height());
self.vars('editor', $('.jqte_editor'));
self.vars('editor').css('height', '100%');
self.vars('editor').css('min-height', self.vars('editor').height());
self.vars('editor').css('height', 'auto');
}
//Fixing "empty" content
var sContent = self.vars('editor').html();
if(sContent=='<br>' || sContent=='') $('#editor').jqteVal(self.vars('default_text'));
if(self.vars('counter')%100==0)
{
//Saving
save(sContent);
}
//Adjust book behaviour
if(!self.vars('working')) checkPageBook();
self.vars('counter', self.vars('counter')+1);
}
})
.jqteVal(sText);
//.jqteVal(self.vars('default_text'));
$('.jqte_tool_4')
.attr('title', 'Bold (Ctrl+B)');
$('.jqte_tool_5')
.attr('title', 'Italic (Ctrl+I)');
$('.jqte_tool_6')
.attr('title', 'Underline (Ctrl+U)');
$('.jqte_tool_7')
.attr('title', 'Numbered List (Ctrl+.)');
$('.jqte_tool_8')
.attr('title', 'List (Ctrl+,)');
$('.jqte_tool_16')
.attr('title', 'Strike through (Ctrl+K)')
.insertAfter($('.jqte_tool_6'));
$('.fa-prev').click(function(){moveToPage(-1);});
$('.fa-next').click(function(){moveToPage(1);});
};
oMyThoughts.onFeedback = function(sType, sMsg)
{
var $Feedback = $('#write_feedback');
$Feedback
.stop()
.fadeOut($Feedback.is(':empty')?0:'fast', function(){
$(this)
.empty()
.append($('<span>', {'class':sType}).text(sMsg))
.fadeIn('fast');
});
};
oMyThoughts.onQuitPage = function()
{
save();
return true;
};
function save(sContent)
{
if(sContent != self.vars('default_text'))
{
oMyThoughts.onFeedback('info', 'Saving...');
getInfo
(
'update',
function(asData)
{
self.vars('id', asData.id_thought);
oMyThoughts.onFeedback('notice', 'Saved ('+asData.led.substr(11, 5)+')');
},
{content:sContent, id:self.vars('id')},
function(sError)
{
oMyThoughts.onFeedback('error', 'Not saved! Un error occured: '+sError);
},
'POST'
);
}
}
function checkPageBook()
{
self.vars('working', true);
var iEm = $(1).toPx();
iEm = iEm.substr(0, iEm.length - 2);
$Editor = self.vars('editor');
//Content Height
var iContentHeight = $Editor.height();
//Calculates what should be displayed
iDisplayedHeight = iContentHeight % self.vars('max_height');
console.log(iContentHeight+'%'+self.vars('max_height')+'='+iDisplayedHeight);
//Navigation
var iNewPage = Math.ceil(iContentHeight / self.vars('max_height'));
moveToPage(iNewPage);
self.vars('prec_displayed', iDisplayedHeight);
self.vars('prec_content', iContentHeight);
self.vars('working', false);
}
function moveToPage(iNewPage)
{
if(iNewPage!=self.vars('page'))
{
self.vars('page', iNewPage);
console.log('moving to page '+self.vars('page'));
$('.page_nb').text(self.vars('page'));
self.vars('editor').css('top', (self.vars('page') - 1)*self.vars('max_height')*-1);
}
}
</script>
+2452
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "mythoughts",
"description": "A journal that behaves like an open book: write the left page, then the right, then turn.",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --mode development --watch",
"prod": "vite build",
"lint": "eslint src"
},
"author": "Franzz",
"dependencies": {
"@fontsource-variable/caveat": "^5.3.0",
"sass": "^1.103.1",
"vue": "^3.5.42"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitejs/plugin-vue": "^6.0.8",
"eslint": "^10.9.1",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.12.0",
"vite": "^8.2.2"
},
"allowScripts": {
"@parcel/watcher@2.6.0": true
}
}
+7
View File
@@ -0,0 +1,7 @@
<?php
require __DIR__.'/../vendor/autoload.php';
use Franzz\MyThoughts\Controller;
echo (new Controller())->handle(__FILE__, $argv ?? []);
+66
View File
@@ -0,0 +1,66 @@
{
"meta": {
"locale": "en_GB",
"page_desc": "A quiet place to write. Your thoughts, on paper."
},
"error": {
"no_auth": "You need to be signed in to do that.",
"not_found": "That does not exist.",
"no_data": "Nothing to show yet.",
"commit_db": "Could not save. Please try again.",
"network": "Could not reach the server. Your writing is kept locally until it comes back.",
"unexpected": "Something went wrong."
},
"account": {
"sign_in": "Sign in",
"sign_up": "Create an account",
"sign_out": "Sign out",
"have_account": "Already have a book?",
"no_account": "Start a new book",
"name": "Your name",
"email": "Email",
"password": "Password",
"remember": "Keep me signed in",
"name_required": "Please tell me what to call you.",
"invalid_email": "That does not look like an email address.",
"password_too_short": "Pick a password of at least $0 characters.",
"invalid_credentials": "Those details do not match a book here.",
"welcome": "Your book is open. Start writing.",
"logged_in": "Welcome back.",
"logged_out": "Your book is closed.",
"saved": "Saved.",
"settings": "Settings",
"language": "Language",
"timezone": "Time zone"
},
"book": {
"title": "MyThoughts",
"tagline": "Your thoughts, on paper.",
"write_here": "Write here…",
"first_page": "This is the first page of your book.",
"no_entry_yet": "Nothing was written around that date.",
"entry_deleted": "That entry was torn out.",
"entries": "Entries",
"bookmarks": "Bookmarks",
"loading": "Turning pages…",
"today": "Today",
"go_to_writing": "Back to today's page",
"empty_entry": "Blank page"
},
"action": {
"prev_page": "Previous page",
"next_page": "Next page",
"calendar": "Jump to a date",
"close": "Close",
"save": "Save",
"delete": "Tear out this entry",
"confirm_delete": "Tear out the entry of $0? This cannot be undone."
},
"save": {
"saving": "Saving…",
"saved": "Saved $0",
"pending": "Unsaved changes",
"failed": "Could not save",
"just_now": "just now"
}
}
+66
View File
@@ -0,0 +1,66 @@
{
"meta": {
"locale": "fr_FR",
"page_desc": "Un endroit calme pour écrire. Vos pensées, sur le papier."
},
"error": {
"no_auth": "Il faut être connecté pour faire ça.",
"not_found": "Ça n'existe pas.",
"no_data": "Rien à afficher pour l'instant.",
"commit_db": "Enregistrement impossible. Réessayez.",
"network": "Serveur injoignable. Votre texte est gardé en local jusqu'à son retour.",
"unexpected": "Quelque chose s'est mal passé."
},
"account": {
"sign_in": "Se connecter",
"sign_up": "Créer un compte",
"sign_out": "Se déconnecter",
"have_account": "Vous avez déjà un carnet ?",
"no_account": "Commencer un carnet",
"name": "Votre nom",
"email": "Email",
"password": "Mot de passe",
"remember": "Rester connecté",
"name_required": "Dites-moi comment vous appeler.",
"invalid_email": "Cette adresse email n'a pas l'air valide.",
"password_too_short": "Choisissez un mot de passe d'au moins $0 caractères.",
"invalid_credentials": "Ces informations ne correspondent à aucun carnet.",
"welcome": "Votre carnet est ouvert. À vous d'écrire.",
"logged_in": "Content de vous revoir.",
"logged_out": "Votre carnet est refermé.",
"saved": "Enregistré.",
"settings": "Réglages",
"language": "Langue",
"timezone": "Fuseau horaire"
},
"book": {
"title": "MyThoughts",
"tagline": "Vos pensées, sur le papier.",
"write_here": "Écrivez ici…",
"first_page": "C'est la première page de votre carnet.",
"no_entry_yet": "Rien n'a été écrit autour de cette date.",
"entry_deleted": "Cette page a été arrachée.",
"entries": "Entrées",
"bookmarks": "Marque-pages",
"loading": "On tourne les pages…",
"today": "Aujourd'hui",
"go_to_writing": "Revenir à la page du jour",
"empty_entry": "Page blanche"
},
"action": {
"prev_page": "Page précédente",
"next_page": "Page suivante",
"calendar": "Aller à une date",
"close": "Fermer",
"save": "Enregistrer",
"delete": "Arracher cette page",
"confirm_delete": "Arracher l'entrée du $0 ? C'est irréversible."
},
"save": {
"saving": "Enregistrement…",
"saved": "Enregistré $0",
"pending": "Modifications non enregistrées",
"failed": "Enregistrement impossible",
"just_now": "à l'instant"
}
}
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="[#]language[#]">
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="[#]lang:meta.page_desc[#]">
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<meta name="theme-color" content="#2c2114">
<meta name="apple-mobile-web-app-title" content="[#]title[#]" />
<link rel="icon" type="image/svg+xml" href="assets/images/icons/favicon.svg" />
<link rel="apple-touch-icon" href="assets/images/icons/apple-touch-icon.svg" />
<link rel="manifest" href="assets/images/icons/site.webmanifest" />
<script id="app-config" type="application/json">[#]app_config[#]</script>
<title>[#]title[#]</title>
<!-- [PART] css [START] -->
<link rel="stylesheet" href="[#]filename[#]" />
<!-- [PART] css [END] -->
<!-- [PART] module [START] -->
<link rel="modulepreload" href="[#]filename[#]" />
<!-- [PART] module [END] -->
<script type="module" src="[#]app_entry[#]"></script>
</head>
<body>
<div id="container"></div>
</body>
</html>
-588
View File
@@ -1,588 +0,0 @@
function emptyBox(element, text)
{
//var textarea = $('#thoughts_form textarea[name="thoughts"]');
if(element.value == text)
{
element.value = '';
}
else if(element.value == '')
{
element.value = text;
}
}
function setHeight(element)
{
var padtext = element.value;
var height = Math.max(300, 130 + Math.round((padtext.length / 85 + padtext.split("\n").length) * 20));
//alert(height);
element.style.height = height+'px';
}
function goTo(url)
{
window.location.href = url;
}
function addInput(form, name, type, value)
{
var registerInput = document.createElement('input');
registerInput.setAttribute('type', type);
registerInput.setAttribute('name', name);
registerInput.setAttribute('value', value);
document.forms[form].appendChild(registerInput);
}
/*
texts = new Object();
texts['thoughts'] = 'Talk to me.';
texts['login'] = 'Nickname';
texts['pass'] = 'Password';
window.onload = function ()
{
for (i in texts)
{
var id = document.getElementById(i);
if(id)
{
id.addEventListener('focus', function() {emptyBox(this, texts[this.name]);}, false);
id.addEventListener('blur', function() {emptyBox(this, texts[this.name]);}, false);
}
}
};
*/
function getInfo(action, fOnSuccess, vars, fOnError, sType/*, bProcessIcon*/)
{
if(!vars) vars = {};
sType = sType || 'GET';
//bProcessIcon = bProcessIcon || false;
//if(bProcessIcon) self.addBufferIcon();
vars['a'] = action;
$.ajax(
{
url: oMyThoughts.consts.context.process_page,
type:sType,
data: vars,
dataType: 'json'
})
.done(function(oData)
{
if(oData.result==oMyThoughts.consts.error)
{
if(!fOnError) console.log(oData.desc);
else fOnError(oData.desc);
}
else
{
//if(bProcessIcon) self.resetIcon();
fOnSuccess(oData);
}
})
.fail(function(jqXHR, textStatus, errorThrown)
{
//if(bProcessIcon) self.resetIcon();
if(!fOnError) console.log(textStatus+' '+errorThrown);
else fOnError(textStatus);
});
}
function feedback(sClass, sMsg, $Box)
{
$Box = $Box || $('#feedback');
sMsg = sMsg || '';
var sHeight = 20;
$('.feedback').each(function(){sHeight += $(this).outerHeight() + 10;});
if(sClass=='error' && sMsg=='') sMsg = 'Oops ! An unknown error occured';
$('<span>', {'class':'feedback round '+sClass})
.css('top', sHeight+'px')
//.append($('<i>', {'class':'fa fa-standalone fa-'+sClass}))
.append(addPunctuation(sMsg))
.appendTo($Box)
.slideDown('fast')
.delay(5000)
.slideUp('fast', function(){$(this).remove();});
};
function addPunctuation(sMsg)
{
var asPunctuations = ['?', '!', '.', ',', ':', ';', '-', '/'];
return sMsg+($.inArray(sMsg.slice(-1), asPunctuations)==-1?'.':'');
};
function copyArray(asArray)
{
return asArray.slice(0); //trick to copy array
}
$.prototype.addButton = function(sType, sTitle, oClickLink, sId, sButtonClass)
{
$This = $(this);
var asAttributes = {id:(sId || ''),
'class':'main-button fa-stack fa-lg'+(typeof sButtonClass != 'undefined'?' '+sButtonClass:''),
title:sTitle};
//Link
var bLink = (typeof oClickLink == 'string');
if(bLink)
{
asAttributes.href = oClickLink;
//asAttributes.target = '_blank';
}
var $Button = $('<a>', asAttributes)
.append($('<i>', {'class':'fa fa-circle fa-stack-2x'}))
.append($('<i>', {'class':'fa fa-stack-1x fa-inverse fa-'+sType}))
//.append($('<span>', {'class':'value'}).text(sTitle))
.appendTo($This);
//Function
if(!bLink) $Button.click(function(e){e.preventDefault(); oClickLink($(this));});
return $This;
};
$.prototype.addDefaultValue = function(sDefaultValue, sInitValue)
{
sInitValue = sInitValue || '';
return $(this)
.data('default_value', sDefaultValue)
.val(sInitValue==''?sDefaultValue:sInitValue)
.addClass(sInitValue==''?'default_text':'')
.focus(function()
{
var $This = $(this);
if($This.val() == $This.data('default_value')) $This.val('');
$This.removeClass('default_text');
})
.blur(function()
{
var $This = $(this);
if($This.val() == '') $This.val($This.data('default_value')).addClass('default_text');
});
};
$.prototype.checkForm = function(sSelector)
{
sSelector = sSelector || 'input[type="password"], input[type="text"], textarea';
var $This = $(this);
var bOk = true;
$This.find(sSelector).each(function()
{
$This = $(this);
bOk = bOk && $This.val()!='' && $This.val()!=$This.data('default_value');
});
return bOk;
};
$.fn.toEm = function(settings){
settings = jQuery.extend({
scope: 'body'
}, settings);
var that = parseInt(this[0],10),
scopeTest = jQuery('<div style="display: none; font-size: 1em; margin: 0; padding:0; height: auto; line-height: 1; border:0;">&nbsp;</div>').appendTo(settings.scope),
scopeVal = scopeTest.height();
scopeTest.remove();
return (that / scopeVal).toFixed(8) + 'em';
};
$.fn.toPx = function(settings){
settings = jQuery.extend({
scope: 'body'
}, settings);
var that = parseFloat(this[0]),
scopeTest = jQuery('<div style="display: none; font-size: 1em; margin: 0; padding:0; height: auto; line-height: 1; border:0;">&nbsp;</div>').appendTo(settings.scope),
scopeVal = scopeTest.height();
scopeTest.remove();
return Math.round(that * scopeVal) + 'px';
};
function getElem(anchor, path)
{
return (typeof path == 'object' && path.length > 1)?getElem(anchor[path.shift()], path):anchor[(typeof path == 'object')?path.shift():path];
}
function setElem(anchor, path, value)
{
if(typeof path == 'object' && path.length > 1)
{
var nextlevel = path.shift();
if(typeof anchor[nextlevel] === 'undefined') anchor[nextlevel] = {};
if(typeof anchor[nextlevel] !== 'object') console.log('Error - setElem() : Already existing path at level'+nextlevel+'. Cancelling setElem() action');
return setElem(anchor[nextlevel], path, value);
}
else return anchor[(typeof path == 'object')?path.shift():path] = value;
}
function getLoginToken(sPass)
{
if(!window.location.origin) window.location.origin = window.location.protocol + "//" + window.location.hostname + (window.location.port ? ':' + window.location.port: '');
return md5(sPass+window.location.origin+window.location.pathname);
}
var defaultDiacriticsRemovalap = [
{'base':'A', 'letters':'\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u00C4\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F'},
{'base':'AA','letters':'\uA732'},
{'base':'AE','letters':'\u00C6\u01FC\u01E2'},
{'base':'AO','letters':'\uA734'},
{'base':'AU','letters':'\uA736'},
{'base':'AV','letters':'\uA738\uA73A'},
{'base':'AY','letters':'\uA73C'},
{'base':'B', 'letters':'\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181'},
{'base':'C', 'letters':'\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E'},
{'base':'D', 'letters':'\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779'},
{'base':'DZ','letters':'\u01F1\u01C4'},
{'base':'Dz','letters':'\u01F2\u01C5'},
{'base':'E', 'letters':'\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E'},
{'base':'F', 'letters':'\u0046\u24BB\uFF26\u1E1E\u0191\uA77B'},
{'base':'G', 'letters':'\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E'},
{'base':'H', 'letters':'\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D'},
{'base':'I', 'letters':'\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197'},
{'base':'J', 'letters':'\u004A\u24BF\uFF2A\u0134\u0248'},
{'base':'K', 'letters':'\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2'},
{'base':'L', 'letters':'\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780'},
{'base':'LJ','letters':'\u01C7'},
{'base':'Lj','letters':'\u01C8'},
{'base':'M', 'letters':'\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C'},
{'base':'N', 'letters':'\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4'},
{'base':'NJ','letters':'\u01CA'},
{'base':'Nj','letters':'\u01CB'},
{'base':'O', 'letters':'\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u00D6\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C'},
{'base':'OI','letters':'\u01A2'},
{'base':'OO','letters':'\uA74E'},
{'base':'OU','letters':'\u0222'},
{'base':'OE','letters':'\u008C\u0152'},
{'base':'oe','letters':'\u009C\u0153'},
{'base':'P', 'letters':'\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754'},
{'base':'Q', 'letters':'\u0051\u24C6\uFF31\uA756\uA758\u024A'},
{'base':'R', 'letters':'\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782'},
{'base':'S', 'letters':'\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784'},
{'base':'T', 'letters':'\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786'},
{'base':'TZ','letters':'\uA728'},
{'base':'U', 'letters':'\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u00DC\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244'},
{'base':'V', 'letters':'\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245'},
{'base':'VY','letters':'\uA760'},
{'base':'W', 'letters':'\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72'},
{'base':'X', 'letters':'\u0058\u24CD\uFF38\u1E8A\u1E8C'},
{'base':'Y', 'letters':'\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE'},
{'base':'Z', 'letters':'\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762'},
{'base':'a', 'letters':'\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u00E4\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250'},
{'base':'aa','letters':'\uA733'},
{'base':'ae','letters':'\u00E6\u01FD\u01E3'},
{'base':'ao','letters':'\uA735'},
{'base':'au','letters':'\uA737'},
{'base':'av','letters':'\uA739\uA73B'},
{'base':'ay','letters':'\uA73D'},
{'base':'b', 'letters':'\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253'},
{'base':'c', 'letters':'\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184'},
{'base':'d', 'letters':'\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A'},
{'base':'dz','letters':'\u01F3\u01C6'},
{'base':'e', 'letters':'\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD'},
{'base':'f', 'letters':'\u0066\u24D5\uFF46\u1E1F\u0192\uA77C'},
{'base':'g', 'letters':'\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F'},
{'base':'h', 'letters':'\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265'},
{'base':'hv','letters':'\u0195'},
{'base':'i', 'letters':'\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131'},
{'base':'j', 'letters':'\u006A\u24D9\uFF4A\u0135\u01F0\u0249'},
{'base':'k', 'letters':'\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3'},
{'base':'l', 'letters':'\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747'},
{'base':'lj','letters':'\u01C9'},
{'base':'m', 'letters':'\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F'},
{'base':'n', 'letters':'\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5'},
{'base':'nj','letters':'\u01CC'},
{'base':'o', 'letters':'\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u00F6\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275'},
{'base':'oi','letters':'\u01A3'},
{'base':'ou','letters':'\u0223'},
{'base':'oo','letters':'\uA74F'},
{'base':'p','letters':'\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755'},
{'base':'q','letters':'\u0071\u24E0\uFF51\u024B\uA757\uA759'},
{'base':'r','letters':'\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783'},
{'base':'s','letters':'\u0073\u24E2\uFF53\u00DF\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B'},
{'base':'t','letters':'\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787'},
{'base':'tz','letters':'\uA729'},
{'base':'u','letters': '\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u00FC\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289'},
{'base':'v','letters':'\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C'},
{'base':'vy','letters':'\uA761'},
{'base':'w','letters':'\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73'},
{'base':'x','letters':'\u0078\u24E7\uFF58\u1E8B\u1E8D'},
{'base':'y','letters':'\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF'},
{'base':'z','letters':'\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763'}
];
var diacriticsMap = {};
for (var i=0; i < defaultDiacriticsRemovalap.length; i++)
{
var letters = defaultDiacriticsRemovalap[i].letters;
for (var j=0; j < letters.length ; j++) diacriticsMap[letters[j]] = defaultDiacriticsRemovalap[i].base;
}
// "what?" version ... http://jsperf.com/diacritics/12
function removeDiacritics(str)
{
return str.replace(/[^\u0000-\u007E]/g, function(a)
{
return diacriticsMap[a] || a;
});
}
function md5(str)
{
var xl;
var rotateLeft = function (lValue, iShiftBits) {
return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));
};
var addUnsigned = function (lX, lY) {
var lX4, lY4, lX8, lY8, lResult;
lX8 = (lX & 0x80000000);
lY8 = (lY & 0x80000000);
lX4 = (lX & 0x40000000);
lY4 = (lY & 0x40000000);
lResult = (lX & 0x3FFFFFFF) + (lY & 0x3FFFFFFF);
if (lX4 & lY4) {
return (lResult ^ 0x80000000 ^ lX8 ^ lY8);
}
if (lX4 | lY4) {
if (lResult & 0x40000000) {
return (lResult ^ 0xC0000000 ^ lX8 ^ lY8);
} else {
return (lResult ^ 0x40000000 ^ lX8 ^ lY8);
}
} else {
return (lResult ^ lX8 ^ lY8);
}
};
var _F = function (x, y, z) {
return (x & y) | ((~x) & z);
};
var _G = function (x, y, z) {
return (x & z) | (y & (~z));
};
var _H = function (x, y, z) {
return (x ^ y ^ z);
};
var _I = function (x, y, z) {
return (y ^ (x | (~z)));
};
var _FF = function (a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(_F(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
};
var _GG = function (a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(_G(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
};
var _HH = function (a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(_H(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
};
var _II = function (a, b, c, d, x, s, ac) {
a = addUnsigned(a, addUnsigned(addUnsigned(_I(b, c, d), x), ac));
return addUnsigned(rotateLeft(a, s), b);
};
var convertToWordArray = function (str) {
var lWordCount;
var lMessageLength = str.length;
var lNumberOfWords_temp1 = lMessageLength + 8;
var lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64;
var lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16;
var lWordArray = new Array(lNumberOfWords - 1);
var lBytePosition = 0;
var lByteCount = 0;
while (lByteCount < lMessageLength) {
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = (lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition));
lByteCount++;
}
lWordCount = (lByteCount - (lByteCount % 4)) / 4;
lBytePosition = (lByteCount % 4) * 8;
lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition);
lWordArray[lNumberOfWords - 2] = lMessageLength << 3;
lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29;
return lWordArray;
};
var wordToHex = function (lValue) {
var wordToHexValue = '',
wordToHexValue_temp = '',
lByte, lCount;
for (lCount = 0; lCount <= 3; lCount++) {
lByte = (lValue >>> (lCount * 8)) & 255;
wordToHexValue_temp = '0' + lByte.toString(16);
wordToHexValue = wordToHexValue + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2);
}
return wordToHexValue;
};
var x = [],
k, AA, BB, CC, DD, a, b, c, d, S11 = 7,
S12 = 12,
S13 = 17,
S14 = 22,
S21 = 5,
S22 = 9,
S23 = 14,
S24 = 20,
S31 = 4,
S32 = 11,
S33 = 16,
S34 = 23,
S41 = 6,
S42 = 10,
S43 = 15,
S44 = 21;
str = utf8_encode(str);
x = convertToWordArray(str);
a = 0x67452301;
b = 0xEFCDAB89;
c = 0x98BADCFE;
d = 0x10325476;
xl = x.length;
for (k = 0; k < xl; k += 16) {
AA = a;
BB = b;
CC = c;
DD = d;
a = _FF(a, b, c, d, x[k + 0], S11, 0xD76AA478);
d = _FF(d, a, b, c, x[k + 1], S12, 0xE8C7B756);
c = _FF(c, d, a, b, x[k + 2], S13, 0x242070DB);
b = _FF(b, c, d, a, x[k + 3], S14, 0xC1BDCEEE);
a = _FF(a, b, c, d, x[k + 4], S11, 0xF57C0FAF);
d = _FF(d, a, b, c, x[k + 5], S12, 0x4787C62A);
c = _FF(c, d, a, b, x[k + 6], S13, 0xA8304613);
b = _FF(b, c, d, a, x[k + 7], S14, 0xFD469501);
a = _FF(a, b, c, d, x[k + 8], S11, 0x698098D8);
d = _FF(d, a, b, c, x[k + 9], S12, 0x8B44F7AF);
c = _FF(c, d, a, b, x[k + 10], S13, 0xFFFF5BB1);
b = _FF(b, c, d, a, x[k + 11], S14, 0x895CD7BE);
a = _FF(a, b, c, d, x[k + 12], S11, 0x6B901122);
d = _FF(d, a, b, c, x[k + 13], S12, 0xFD987193);
c = _FF(c, d, a, b, x[k + 14], S13, 0xA679438E);
b = _FF(b, c, d, a, x[k + 15], S14, 0x49B40821);
a = _GG(a, b, c, d, x[k + 1], S21, 0xF61E2562);
d = _GG(d, a, b, c, x[k + 6], S22, 0xC040B340);
c = _GG(c, d, a, b, x[k + 11], S23, 0x265E5A51);
b = _GG(b, c, d, a, x[k + 0], S24, 0xE9B6C7AA);
a = _GG(a, b, c, d, x[k + 5], S21, 0xD62F105D);
d = _GG(d, a, b, c, x[k + 10], S22, 0x2441453);
c = _GG(c, d, a, b, x[k + 15], S23, 0xD8A1E681);
b = _GG(b, c, d, a, x[k + 4], S24, 0xE7D3FBC8);
a = _GG(a, b, c, d, x[k + 9], S21, 0x21E1CDE6);
d = _GG(d, a, b, c, x[k + 14], S22, 0xC33707D6);
c = _GG(c, d, a, b, x[k + 3], S23, 0xF4D50D87);
b = _GG(b, c, d, a, x[k + 8], S24, 0x455A14ED);
a = _GG(a, b, c, d, x[k + 13], S21, 0xA9E3E905);
d = _GG(d, a, b, c, x[k + 2], S22, 0xFCEFA3F8);
c = _GG(c, d, a, b, x[k + 7], S23, 0x676F02D9);
b = _GG(b, c, d, a, x[k + 12], S24, 0x8D2A4C8A);
a = _HH(a, b, c, d, x[k + 5], S31, 0xFFFA3942);
d = _HH(d, a, b, c, x[k + 8], S32, 0x8771F681);
c = _HH(c, d, a, b, x[k + 11], S33, 0x6D9D6122);
b = _HH(b, c, d, a, x[k + 14], S34, 0xFDE5380C);
a = _HH(a, b, c, d, x[k + 1], S31, 0xA4BEEA44);
d = _HH(d, a, b, c, x[k + 4], S32, 0x4BDECFA9);
c = _HH(c, d, a, b, x[k + 7], S33, 0xF6BB4B60);
b = _HH(b, c, d, a, x[k + 10], S34, 0xBEBFBC70);
a = _HH(a, b, c, d, x[k + 13], S31, 0x289B7EC6);
d = _HH(d, a, b, c, x[k + 0], S32, 0xEAA127FA);
c = _HH(c, d, a, b, x[k + 3], S33, 0xD4EF3085);
b = _HH(b, c, d, a, x[k + 6], S34, 0x4881D05);
a = _HH(a, b, c, d, x[k + 9], S31, 0xD9D4D039);
d = _HH(d, a, b, c, x[k + 12], S32, 0xE6DB99E5);
c = _HH(c, d, a, b, x[k + 15], S33, 0x1FA27CF8);
b = _HH(b, c, d, a, x[k + 2], S34, 0xC4AC5665);
a = _II(a, b, c, d, x[k + 0], S41, 0xF4292244);
d = _II(d, a, b, c, x[k + 7], S42, 0x432AFF97);
c = _II(c, d, a, b, x[k + 14], S43, 0xAB9423A7);
b = _II(b, c, d, a, x[k + 5], S44, 0xFC93A039);
a = _II(a, b, c, d, x[k + 12], S41, 0x655B59C3);
d = _II(d, a, b, c, x[k + 3], S42, 0x8F0CCC92);
c = _II(c, d, a, b, x[k + 10], S43, 0xFFEFF47D);
b = _II(b, c, d, a, x[k + 1], S44, 0x85845DD1);
a = _II(a, b, c, d, x[k + 8], S41, 0x6FA87E4F);
d = _II(d, a, b, c, x[k + 15], S42, 0xFE2CE6E0);
c = _II(c, d, a, b, x[k + 6], S43, 0xA3014314);
b = _II(b, c, d, a, x[k + 13], S44, 0x4E0811A1);
a = _II(a, b, c, d, x[k + 4], S41, 0xF7537E82);
d = _II(d, a, b, c, x[k + 11], S42, 0xBD3AF235);
c = _II(c, d, a, b, x[k + 2], S43, 0x2AD7D2BB);
b = _II(b, c, d, a, x[k + 9], S44, 0xEB86D391);
a = addUnsigned(a, AA);
b = addUnsigned(b, BB);
c = addUnsigned(c, CC);
d = addUnsigned(d, DD);
}
var temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d);
return temp.toLowerCase();
}
function utf8_encode(argString)
{
if (argString === null || typeof argString === 'undefined') {
return '';
}
// .replace(/\r\n/g, "\n").replace(/\r/g, "\n");
var string = (argString + '');
var utftext = '',
start, end, stringl = 0;
start = end = 0;
stringl = string.length;
for (var n = 0; n < stringl; n++) {
var c1 = string.charCodeAt(n);
var enc = null;
if (c1 < 128) {
end++;
} else if (c1 > 127 && c1 < 2048) {
enc = String.fromCharCode(
(c1 >> 6) | 192, (c1 & 63) | 128
);
} else if ((c1 & 0xF800) != 0xD800) {
enc = String.fromCharCode(
(c1 >> 12) | 224, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128
);
} else {
// surrogate pairs
if ((c1 & 0xFC00) != 0xD800) {
throw new RangeError('Unmatched trail surrogate at ' + n);
}
var c2 = string.charCodeAt(++n);
if ((c2 & 0xFC00) != 0xDC00) {
throw new RangeError('Unmatched lead surrogate at ' + (n - 1));
}
c1 = ((c1 & 0x3FF) << 10) + (c2 & 0x3FF) + 0x10000;
enc = String.fromCharCode(
(c1 >> 18) | 240, ((c1 >> 12) & 63) | 128, ((c1 >> 6) & 63) | 128, (c1 & 63) | 128
);
}
if (enc !== null) {
if (end > start) {
utftext += string.slice(start, end);
}
utftext += enc;
start = end = n + 1;
}
}
if (end > start) {
utftext += string.slice(start, stringl);
}
return utftext;
}
File diff suppressed because one or more lines are too long
-4
View File
File diff suppressed because one or more lines are too long
-232
View File
@@ -1,232 +0,0 @@
function MyThoughts(asGlobals)
{
self = this;
this.consts = asGlobals.consts;
this.consts.hash_sep = '-';
this.consts.default_page = 'home';
this.consts.title = 'My Thoughts';
this.init = function()
{
//Variables & constants from php
self.vars('tmp', 'object');
self.vars('page', 'string');
self.vars('template', 'boolean');
self.updateVars(asGlobals.vars);
//page elem
self.elem = {};
self.elem.container = $('#container');
//on window resize
$(window).resize(self.onResize).resize();
//Setup menu
//self.initMenu();
//Hash management
self.resetTmpFunctions();
$(window)
.bind('hashchange', self.onHashChange)
.trigger('hashchange');
};
this.updateVars = function(asVars)
{
$.each(asVars, function(sKey, oValue){self.vars(sKey, oValue)});
};
/* Scrollbar */
/*this.scrollbar = function(sPos)
{
var $Cv = $('#main');
if(!self.vars('mobile'))
{
if(typeof self.vars('scrollbar') === 'undefined')
{
$Cv.tinyscrollbar({axis:'y', thumbSizeMin:'20', thumbSize:'20'});
self.vars('scrollbar', $Cv.data("plugin_tinyscrollbar"));
}
else self.vars('scrollbar').update(sPos);
}
else $Cv.unbind("tinyscrollbar");
}*/
/* Menu */
this.initMenu = function()
{
};
/* Events */
this.onResize = function()
{
self.vars('mobile', $('body').css('min-width')=='120px');
//self.scrollbar();
};
this.onHashChange = function()
{
var asHash = self.getHash();
var sDefaultPage = self.vars('log_in')?'write':'logon';
if(asHash.hash !='' && asHash.page != '') self.switchPage(asHash); //page switching
else if(self.vars('page')=='') self.setHash(sDefaultPage); //first page
};
this.resetTmpFunctions = function()
{
self.pageInit = function(asHash){console.log('no init for the page: '+asHash.page)};
self.onSamePageMove = function(asHash){return false};
self.onQuitPage = function(){return true};
self.onFeedback = function(sType, sMsg){feedback(sType, sMsg, self.elem.container);};
};
/* Hash Handling */
this.getHash = function()
{
var sHash = self.hash();
var asHash = sHash.split(self.consts.hash_sep);
var sPage = asHash.shift() || '';
return {hash:sHash, page:sPage, items:asHash};
};
this.setHash = function(sPage, asItems, bReboot)
{
bReboot = bReboot || false;
sPage = sPage || '';
asItems = asItems || [];
if(typeof asItems == 'string') asItems = [asItems];
if(sPage != '')
{
var sItems = (asItems.length > 0)?self.consts.hash_sep+asItems.join(self.consts.hash_sep):'';
self.hash(sPage+sItems, bReboot);
}
};
this.hash = function(hash, bReboot)
{
bReboot = bReboot || false;
if(!hash) return window.location.hash.slice(1);
else window.location.hash = '#'+hash;
if(bReboot) location.reload();
};
this.getVars = function(fOnSuccess)
{
fOnSuccess = fOnSuccess || function(){};
getInfo
(
'vars',
function(asData)
{
self.updateVars(asData.vars);
fOnSuccess();
},
{}
);
};
/* Page Switching */
this.getActionLink = function(sAction, oVars)
{
if(!oVars) oVars = {};
sVars = '';
for(i in oVars)
{
sVars += '&'+i+'='+oVars[i];
}
return self.consts.process_page+'?a='+sAction+sVars;
};
this.switchPage = function(asHash)
{
var sPageName = asHash.page;
var bSamePage = self.vars('page')==sPageName;
if(self.onQuitPage(bSamePage) && !bSamePage || self.onSamePageMove(asHash))
{
//Preload template if not already loaded
//Delete tmp variables
self.vars('tmp', {});
//disable tmp functions
self.resetTmpFunctions();
//Officially a new page
var bFirstPage = self.vars('page')=='';
self.vars('page', sPageName);
//Update Page Title
var sDetail = asHash.items[0] || '';
document.title = self.consts.title+' - '+sPageName+' '+sDetail;
//Replacing DOM
var $Dom = $(self.consts.pages[sPageName]);
if(bFirstPage)
{
self.elem.container.html($(self.consts.pages['template']));
self.elem.main = self.elem.container.find('#main');
self.splash(self.elem.main, $Dom, asHash, bFirstPage); //first page
}
else
{
self.elem.main.stop().fadeTo('fast', 0, function(){self.splash(self.elem.main, $Dom, asHash, bFirstPage);}); //Switching page
}
}
};
this.splash = function($FadeInElem, $Dom, asHash, bFirstPage)
{
//Switch main content
$FadeInElem.empty();
$FadeInElem.html($Dom);
//Page Bootstrap
self.pageInit(asHash, bFirstPage);
//Show main
$FadeInElem.fadeTo('fast', 1, function(){});
};
/* Variables Handling */
this.vars = function(sVarName, oValue)
{
var asTypes = {boolean:false, string:'', integer:0, array:[], object:{}};
var asVarName = (typeof sVarName == 'object')?sVarName:[sVarName];
var sFirstVarName = asVarName[0];
//Get, only name parameter
if(typeof oValue == 'undefined')
{
return getElem(self.vars, asVarName);
}
//Init, name & type / default value
else if(typeof oValue !== 'undefined' && typeof self.vars[sFirstVarName] === 'undefined')
{
self.vars[sFirstVarName] = (typeof asTypes[oValue] !== 'undefined')?asTypes[oValue]:oValue;
return self.vars[sFirstVarName];
}
//Set, name & value
else if(typeof oValue !== 'undefined' && typeof self.vars[sFirstVarName] !== 'undefined')
{
setElem(self.vars, asVarName, oValue);
return getElem(self.vars, asVarName);
}
return null;
};
this.tmp = function(sVarName, oValue)
{
var asVarName = (typeof sVarName == 'object')?sVarName:[sVarName];
asVarName.unshift('tmp');
return self.vars(asVarName, oValue);
};
}
-2
View File
File diff suppressed because one or more lines are too long
-16
View File
@@ -1,16 +0,0 @@
<?php
class Settings
{
const DB_SERVER = 'localhost';
const DB_LOGIN = '';
const DB_PASS = '';
const DB_NAME = 'mythoughts';
const DB_ENC = 'utf8mb4';
const TEXT_ENC = 'UTF-8';
const TIMEZONE = 'Pacific/Auckland';
const API_KEY = 'MY_API_KEY';
const DEBUG = true; //prod: false, dev: true
}
?>
+306
View File
@@ -0,0 +1,306 @@
<script>
import appIcon from '@components/AppIcon';
import authPanel from '@components/AuthPanel';
import book from '@components/Book';
import bookmarkRail from '@components/BookmarkRail';
import calendarWidget from '@components/CalendarWidget';
import saveIndicator from '@components/SaveIndicator';
import settingsPanel from '@components/SettingsPanel';
import sLogo from '@images/logo.png';
/**
* The desk: the book, the tabs down its side, and the few controls that are not
* part of the book itself.
*
* This also owns the one piece of lifecycle that cannot live in a component:
* sealing the open entry when the page goes away. It has to be a beacon - a
* fetch issued during unload is cancelled along with the document - and it
* closes the entry only, never the login.
*/
export default {
components: {
appIcon,
authPanel,
book,
bookmarkRail,
calendarWidget,
saveIndicator,
settingsPanel
},
inject: ['api', 'consts', 'journal', 'lang', 'user'],
data() {
return {
sLogo,
bLoaded: false,
bCalendarOpen: false,
bSettingsOpen: false,
bMenuOpen: false,
bRailOpen: false,
iCurrentId: 0,
sCurrentDay: '',
sNotice: '',
bNoticeBad: false
};
},
computed: {
signedIn() {
return (this.user.id > 0);
},
days() {
return this.journal.days;
}
},
watch: {
signedIn: {
immediate: true,
handler(bSignedIn) {
if(bSignedIn) this.openBook();
}
}
},
mounted() {
//pagehide is the one that fires reliably on mobile; beforeunload does
//not on iOS, and visibilitychange fires on every tab switch.
window.addEventListener('pagehide', this.onLeave);
window.addEventListener('beforeunload', this.onLeave);
document.addEventListener('visibilitychange', this.onVisibilityChange);
document.addEventListener('keydown', this.onKeydown);
},
beforeUnmount() {
window.removeEventListener('pagehide', this.onLeave);
window.removeEventListener('beforeunload', this.onLeave);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
document.removeEventListener('keydown', this.onKeydown);
},
methods: {
async openBook() {
try {
await this.journal.load();
//Arriving at the book should mean you can just write - so the
//page being written on is claimed up front. An entry nothing was
//written in is discarded when it closes, so this costs nothing.
if(this.journal.openId === 0) await this.journal.startWriting();
this.bLoaded = true;
this.$nextTick(() => this.$refs.book?.goToWriting());
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
onReading({id, day}) {
this.iCurrentId = id;
this.sCurrentDay = day;
},
/* Getting about */
async onPickBookmark(iEntryId) {
this.bRailOpen = false;
await this.$refs.book?.goToEntry(iEntryId);
},
async onPickDate(sDay) {
this.bCalendarOpen = false;
try {
const iEntryId = await this.journal.findEntryAtDate(sDay);
if(iEntryId > 0) await this.$refs.book?.goToEntry(iEntryId);
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
async onLoadOlder() {
try {
await this.$refs.book?.extendBackwards();
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
goToWriting() {
this.$refs.book?.goToWriting();
},
/* Account */
onSignedIn(asUser) {
Object.assign(this.user, asUser);
},
async onSignedOut() {
this.bSettingsOpen = false;
this.bMenuOpen = false;
//Everything about the previous book has to go, or the next reader
//would be handed its pages.
this.journal.entries = [];
this.journal.bookmarks = [];
this.journal.openId = 0;
this.bLoaded = false;
Object.assign(this.user, {id: 0, name: '', email: ''});
},
/* Leaving */
onLeave() {
//Whatever is on the page stays as an entry; the login is untouched.
this.journal.closeOnUnload();
},
onVisibilityChange() {
//A backgrounded tab may never come back, so flush before it goes.
if(document.visibilityState === 'hidden' && this.journal.isDirty) this.journal.save();
},
onKeydown(oEvent) {
if(oEvent.key !== 'Escape') return;
if(this.bCalendarOpen) this.bCalendarOpen = false;
else if(this.bMenuOpen) this.bMenuOpen = false;
else if(this.bRailOpen) this.bRailOpen = false;
},
notify(sMessage, bBad = false) {
this.sNotice = sMessage;
this.bNoticeBad = bBad;
},
closeMenus() {
this.bCalendarOpen = false;
this.bMenuOpen = false;
}
}
};
</script>
<template>
<div class="app">
<header class="app__header">
<h1 class="app__brand">
<img class="app__logo" :src="sLogo" :alt="consts.title" />
<span class="app__tagline">{{ lang.get('book.tagline') }}</span>
</h1>
<div class="app__tools">
<saveIndicator v-if="signedIn && bLoaded" />
<template v-if="signedIn">
<button
type="button"
class="desk-button"
:title="lang.get('book.go_to_writing')"
@click="goToWriting"
>
<appIcon icon="pen" />
</button>
<div class="app__popover-anchor">
<button
type="button"
class="desk-button"
:aria-expanded="bCalendarOpen"
:title="lang.get('action.calendar')"
@click="bCalendarOpen = !bCalendarOpen; bMenuOpen = false"
>
<appIcon icon="calendar" />
</button>
<div v-if="bCalendarOpen" class="app__popover">
<calendarWidget
:days="days"
:current-day="sCurrentDay"
@pick="onPickDate"
/>
</div>
</div>
<button
type="button"
class="desk-button desk-button--icon app__rail-toggle"
:title="lang.get('book.bookmarks')"
@click="bRailOpen = !bRailOpen"
>
<appIcon icon="bookmark" />
</button>
<div class="app__popover-anchor">
<button
type="button"
class="desk-button"
:aria-expanded="bMenuOpen"
:title="lang.get('account.settings')"
@click="bMenuOpen = !bMenuOpen; bCalendarOpen = false"
>
<appIcon icon="user" />
</button>
<div v-if="bMenuOpen" class="app__popover account-menu">
<div class="account-menu__who">
<div class="account-menu__name">{{ user.name }}</div>
<div class="account-menu__email">{{ user.email }}</div>
</div>
<button
type="button"
class="account-menu__item"
@click="bSettingsOpen = true; bMenuOpen = false"
>
<appIcon icon="settings" />
<span>{{ lang.get('account.settings') }}</span>
</button>
</div>
</div>
</template>
</div>
</header>
<main class="app__desk" @click="closeMenus">
<div class="app__book">
<book
v-if="signedIn && bLoaded"
ref="book"
@reading="onReading"
/>
</div>
<div
v-if="signedIn && bLoaded"
class="app__rail"
:class="bRailOpen ? 'app__rail--open' : null"
>
<bookmarkRail
:bookmarks="journal.bookmarks"
:current-id="iCurrentId"
:open-id="journal.openId"
:has-older="journal.hasOlder"
:loading="journal.loading"
@pick="onPickBookmark"
@load-older="onLoadOlder"
/>
</div>
</main>
<div v-if="sNotice" class="app__notice" :class="bNoticeBad ? 'app__notice--bad' : null" role="status">
<appIcon :icon="bNoticeBad ? 'alert' : 'check'" />
<span>{{ sNotice }}</span>
<button type="button" class="app__notice-close" @click="sNotice = ''">
<appIcon icon="close" size="0.9em" />
</button>
</div>
<authPanel v-if="!signedIn" @done="onSignedIn" />
<settingsPanel
v-if="bSettingsOpen"
@close="bSettingsOpen = false"
@signed-out="onSignedOut"
/>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
//Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api';
import Journal from '@scripts/journal';
import Lang from '@scripts/lang';
import { getBrowserTimezone } from '@scripts/time';
import { createApp, reactive } from 'vue';
//Main template
import App from './App.vue';
//Style
import '@styles/mythoughts.scss';
//App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
//Instances
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
//The locale lives in the dictionary rather than in a const, so a new language
//file brings its own date formatting with it.
oLang.locale = (oLang.get('meta.locale') || 'en').replace('_', '-');
//The browser's zone is the one thing PHP cannot know before the first request,
//so every call carries it and the server formats no dates at all.
const sTimezone = getBrowserTimezone() || appConfig.user.timezone || appConfig.consts.default_timezone;
const oApi = new Api({
server: appConfig.consts.server,
processPage: appConfig.consts.process_page,
timezone: sTimezone,
csrfToken: appConfig.consts.csrf_token,
errorCode: appConfig.consts.error,
lang: oLang
});
const oUser = reactive({...appConfig.user});
const oJournal = reactive(new Journal(oApi, appConfig.consts));
//Mount app
const oApp = createApp(App);
oApp.provide('api', oApi);
oApp.provide('consts', appConfig.consts);
oApp.provide('journal', oJournal);
oApp.provide('lang', oLang);
oApp.provide('timezone', sTimezone);
oApp.provide('user', oUser);
oApp.mount('#container');
+44
View File
@@ -0,0 +1,44 @@
<script>
import { getIconPaths, hasIcon } from '@scripts/icons';
export default {
props: {
icon: String,
size: {type: String, default: '1.15em'},
title: String
},
computed: {
paths() {
if(!hasIcon(this.icon)) console.warn('Missing icon:', this.icon);
return getIconPaths(this.icon);
}
}
};
</script>
<template>
<svg
class="app-icon"
:class="'app-icon--' + icon"
:style="{width: size, height: size}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
:role="title ? 'img' : 'presentation'"
:aria-hidden="title ? null : 'true'"
>
<title v-if="title">{{ title }}</title>
<path v-for="(sPath, iIndex) in paths" :key="iIndex" :d="sPath" />
</svg>
</template>
<style lang="scss">
.app-icon {
flex: 0 0 auto;
display: block;
overflow: visible;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script>
import appIcon from '@components/AppIcon';
import sLogo from '@images/logo.png';
/**
* The closed book: sign in, or start a new one.
*
* The two modes are one form because they differ by a single field - splitting
* them into separate components would duplicate the whole submit path for the
* sake of a name input.
*/
export default {
components: {
appIcon
},
emits: ['done'],
inject: ['api', 'consts', 'lang', 'timezone'],
data() {
return {
sLogo,
bNewBook: false,
sName: '',
sEmail: '',
sPassword: '',
bRemember: true,
sError: '',
bBusy: false
};
},
computed: {
canSubmit() {
return !this.bBusy && (this.sEmail.trim() !== '') && (this.sPassword !== '') && (!this.bNewBook || (this.sName.trim() !== ''));
}
},
mounted() {
this.$refs.first?.focus();
},
methods: {
toggleMode() {
this.bNewBook = !this.bNewBook;
this.sError = '';
this.$nextTick(() => this.$refs.first?.focus());
},
async submit() {
if(!this.canSubmit) return;
this.bBusy = true;
this.sError = '';
try {
const asData = this.bNewBook ?
await this.api.post('signup', {
name: this.sName,
email: this.sEmail,
password: this.sPassword,
t: this.timezone
})
:
await this.api.post('login', {
email: this.sEmail,
password: this.sPassword,
remember: this.bRemember ? 1 : 0,
t: this.timezone
});
//Nothing typed here should outlive the successful submit.
this.sPassword = '';
this.$emit('done', asData.user);
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
this.sPassword = '';
this.$refs.password?.focus();
}
finally {
this.bBusy = false;
}
}
}
};
</script>
<template>
<div class="veil">
<form class="leaf" @submit.prevent="submit">
<div class="leaf__head">
<!-- Paper at last: the logo's own colours, as it was drawn -->
<img class="leaf__logo" :src="sLogo" alt="MyThoughts" />
<h2 class="leaf__title">
{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}
</h2>
<p class="leaf__sub">{{ lang.get('book.tagline') }}</p>
</div>
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
<div v-if="bNewBook" class="leaf__row">
<label class="paper-label" for="auth-name">{{ lang.get('account.name') }}</label>
<input
id="auth-name"
ref="first"
v-model="sName"
class="paper-field"
type="text"
autocomplete="name"
maxlength="100"
required
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="auth-email">{{ lang.get('account.email') }}</label>
<input
id="auth-email"
:ref="bNewBook ? null : 'first'"
v-model="sEmail"
class="paper-field"
type="email"
autocomplete="email"
required
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="auth-password">{{ lang.get('account.password') }}</label>
<input
id="auth-password"
ref="password"
v-model="sPassword"
class="paper-field"
type="password"
:autocomplete="bNewBook ? 'new-password' : 'current-password'"
required
/>
</div>
<label v-if="!bNewBook" class="leaf__check">
<input v-model="bRemember" type="checkbox" />
<span>{{ lang.get('account.remember') }}</span>
</label>
<div class="leaf__actions">
<button type="submit" class="ink-button" :disabled="!canSubmit">
<appIcon :icon="bNewBook ? 'pen' : 'book'" />
<span>{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}</span>
</button>
</div>
<p class="leaf__note">
<button type="button" class="text-button" @click="toggleMode">
{{ bNewBook ? lang.get('account.have_account') : lang.get('account.no_account') }}
</button>
</p>
</form>
</div>
</template>
+807
View File
@@ -0,0 +1,807 @@
<script>
import bookPage from '@components/BookPage';
import appIcon from '@components/AppIcon';
import Paginator from '@scripts/paginator';
import { getDayKey } from '@scripts/time';
//Matches $mobile in _var.scss: below this a spread cannot hold two pages, so
//the book paginates one page per view instead.
const SINGLE_PAGE_WIDTH = 860;
const TURN_MS = 700;
/**
* The open book.
*
* Three things meet here and nowhere else:
*
* - Measurement. One hidden element, laid out by the browser at the exact width
* of a real text column, decides where every line breaks. Nothing guesses at
* text metrics.
* - Flow. The paginator turns the journal into visual lines, the lines into
* pages, and the pages into spreads - so an entry runs off the left page onto
* the right one and on into the next spread, and a new entry simply starts on
* the line after the last one ended.
* - Writing. A textarea cannot flow across two columns, so it is not what you
* look at: it holds the keystrokes, the selection and the arrow keys, while
* the book draws the glyphs, the caret and the selection itself. It is sized
* to exactly one column, which makes its own soft wrapping agree with the
* paginator's - so Up and Down still move by the lines you can see.
*/
export default {
components: {
appIcon,
bookPage
},
emits: ['reading'],
inject: ['journal', 'lang'],
data() {
return {
iSpread: 0,
iPagesPerView: 2,
asLayout: {pages: [[]], index: new Map(), lineCount: 0, linesPerPage: 1},
iLineHeight: 0,
iLinesPerPage: 0,
//Geometry of the text column the input and the measurer sit on,
//relative to the spread
asTextBox: {left: 0, top: 0, width: 0, height: 0},
iCaretOffset: 0,
iSelectionStart: 0,
iSelectionEnd: 0,
bFocused: false,
asCaret: null,
asSelection: [],
//The leaf in flight: {id, dir, front, back, from} while a page turns
asTurn: null,
iTurnId: 0,
bReady: false
};
},
computed: {
entriesById() {
const asEntries = new Map();
for(const oEntry of this.journal.entries) asEntries.set(oEntry.id, oEntry);
return asEntries;
},
pages() {
const aoPages = this.asLayout.pages || [[]];
//The paginator pads to an even number of pages so a spread is always
//full. One page per view has no facing page to fill, so the padding
//would just be a blank screen at the end of the book.
if((this.iPagesPerView === 1) && (aoPages.length > 1) && (aoPages.at(-1).length === 0)) {
return aoPages.slice(0, -1);
}
return aoPages;
},
//Pages are uniform chunks, so a flat line index maps straight back to a
//page - which is what makes "where is the caret" a lookup, not a walk.
flatLines() {
return this.pages.flat();
},
spreadCount() {
return Math.max(1, Math.ceil(this.pages.length / this.iPagesPerView));
},
visiblePages() {
const iFirst = this.iSpread * this.iPagesPerView;
const aoVisible = [];
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
const iPage = iFirst + iOffset;
aoVisible.push({index: iPage, lines: this.pages[iPage] || []});
}
//Mid-turn, the half the leaf lifted from still shows the page it
//lifted from - it is only covered when the leaf lands on it. So the
//spread underneath is a mix: one side already turned, one not yet.
if(this.asTurn && (this.iPagesPerView > 1)) {
const iStale = (this.asTurn.dir === 'forward') ? 0 : 1;
const iStalePage = (this.asTurn.from * this.iPagesPerView) + iStale;
aoVisible[iStale] = {index: iStalePage, lines: this.pages[iStalePage] || []};
}
return aoVisible;
},
openEntry() {
return this.journal.openEntry;
},
entryCount() {
return this.journal.entries.length;
},
caretPosition() {
const oOpen = this.openEntry;
if(!oOpen) return null;
const iOffset = Math.max(0, Math.min(this.iCaretOffset, (oOpen.content || '').length));
const asLines = this.flatLines;
let iFound = -1;
for(let iLine = 0; iLine < asLines.length; iLine++) {
const oLine = asLines[iLine];
if(oLine.id !== oOpen.id) continue;
//Lines of the open entry are contiguous and in order, so the
//last one starting at or before the caret is the caret's line.
if(oLine.start <= iOffset) iFound = iLine;
else break;
}
if(iFound < 0) return null;
const iPerPage = Math.max(1, this.asLayout.linesPerPage);
const iPage = Math.floor(iFound / iPerPage);
return {
global: iFound,
page: iPage,
line: iFound % iPerPage,
spread: Math.floor(iPage / this.iPagesPerView),
offset: iOffset
};
},
//What the rail highlights and the calendar opens on: the entry the
//visible spread starts with.
currentEntryId() {
for(const oPage of this.visiblePages) {
if(oPage.lines.length > 0) return oPage.lines[0].id;
}
return this.journal.openId;
},
currentDay() {
const oEntry = this.entriesById.get(this.currentEntryId);
return oEntry ? getDayKey(oEntry.time, oEntry.timezone) : '';
},
canTurnBack() {
return (this.iSpread > 0) || this.journal.hasOlder;
},
canTurnForward() {
return (this.iSpread < this.spreadCount - 1) || this.journal.hasNewer;
},
//The ribbon marks the spread being written on
showRibbon() {
const oPos = this.caretPosition;
return (this.iPagesPerView > 1) && (oPos !== null) && (oPos.spread === this.iSpread);
},
measureStyle() {
return {
left: this.asTextBox.left + 'px',
top: this.asTextBox.top + 'px',
width: this.asTextBox.width + 'px'
};
},
inputStyle() {
return {
left: this.asTextBox.left + 'px',
top: this.asTextBox.top + 'px',
width: this.asTextBox.width + 'px',
height: this.asTextBox.height + 'px'
};
}
},
watch: {
//A new entry was opened, or the book was reloaded around a date
'journal.openId'() {
this.$nextTick(() => this.refresh());
},
entryCount() {
this.relayout();
this.$nextTick(() => this.paintOverlay());
},
iPagesPerView() {
this.$nextTick(() => this.measure());
},
//The rail highlights, and the calendar opens on, whatever is being read
currentEntryId: {
immediate: true,
handler(iEntryId) {
this.$emit('reading', {id: iEntryId, day: this.currentDay});
}
}
},
created() {
//None of these are state the template renders, and the paginator holds
//DOM nodes and a cache - proxying them would cost on every keystroke.
this.oPaginator = new Paginator();
this.oRange = document.createRange();
this.oResizeObserver = null;
this.oTurnTimer = null;
this.oMedia = null;
},
async mounted() {
this.oMedia = window.matchMedia('(max-width: ' + SINGLE_PAGE_WIDTH + 'px)');
this.iPagesPerView = this.oMedia.matches ? 1 : 2;
this.oMedia.addEventListener('change', this.onMediaChange);
//The paginator measures whatever font is actually loaded, so measuring
//before the hand arrives would lay the whole book out in the fallback.
if(document.fonts?.ready) await document.fonts.ready;
await this.measure();
this.oResizeObserver = new ResizeObserver(() => this.measure());
this.oResizeObserver.observe(this.$refs.book);
document.addEventListener('selectionchange', this.onSelectionChange);
this.refresh();
this.goToWriting();
},
beforeUnmount() {
this.oResizeObserver?.disconnect();
this.oMedia?.removeEventListener('change', this.onMediaChange);
document.removeEventListener('selectionchange', this.onSelectionChange);
clearTimeout(this.oTurnTimer);
},
methods: {
onMediaChange(oEvent) {
this.iPagesPerView = oEvent.matches ? 1 : 2;
},
/* Measuring */
/**
* Resolve the two numbers the whole layout hangs on - how tall a ruled
* line is, and how many of them fit on a page - and hand them to the
* paginator. Everything else follows from those.
*/
async measure() {
const oBook = this.$refs.book;
const oMeasure = this.$refs.measure;
if(!oBook || !oMeasure) return;
//The measurer has to sit at the width of a real column before its
//line height means anything.
this.readTextBox();
await this.$nextTick();
const iLineHeight = parseFloat(getComputedStyle(oMeasure).lineHeight) || 0;
if(iLineHeight <= 0) return;
const oBody = this.getPageComponent(this.iSpread * this.iPagesPerView)?.getBodyElement();
if(!oBody) return;
//Whole rules only: a page that ends on half a line looks like a bug.
const iLines = Math.max(1, Math.floor(oBody.clientHeight / iLineHeight));
this.iLineHeight = iLineHeight;
this.iLinesPerPage = iLines;
oBook.style.setProperty('--book-lines', String(iLines));
await this.$nextTick();
if(this.oPaginator.setMetrics(oMeasure, iLineHeight, iLines)) this.relayout();
this.bReady = true;
this.$nextTick(() => this.paintOverlay());
},
//Geometry of the column the input and the measurer overlay
readTextBox() {
const oSpread = this.$refs.spread;
if(!oSpread) return;
const oPos = this.caretPosition;
const iAnchor = oPos ? oPos.page : (this.iSpread * this.iPagesPerView);
const oPage = this.getPageComponent(iAnchor) || this.getPageComponent(this.iSpread * this.iPagesPerView);
const oLines = oPage?.getLinesElement();
if(!oLines) return;
const oSpreadRect = oSpread.getBoundingClientRect();
const oLinesRect = oLines.getBoundingClientRect();
this.asTextBox = {
left: oLinesRect.left - oSpreadRect.left,
top: oLinesRect.top - oSpreadRect.top,
width: oLinesRect.width,
height: Math.max(1, this.iLinesPerPage) * Math.max(1, this.iLineHeight)
};
},
relayout() {
if(!this.oPaginator.ready) return;
this.asLayout = this.oPaginator.layout(this.journal.entries);
},
/* Drawing what the input cannot */
paintOverlay() {
this.readTextBox();
const oPos = this.caretPosition;
if(oPos && this.bFocused && (oPos.spread === this.iSpread)) {
const oPage = this.getPageComponent(oPos.page);
const oLine = this.flatLines[oPos.global];
this.asCaret = (oPage && oLine) ?
{page: oPos.page, line: oPos.line, x: this.getCaretX(oPage, oPos.line, oLine, oPos.offset - oLine.start)}
:
null;
}
else this.asCaret = null;
this.asSelection = this.buildSelection();
},
getCaretX(oPage, iLine, oLine, iChars) {
const oLineEl = oPage.getLineElement(iLine);
const oLinesEl = oPage.getLinesElement();
if(!oLineEl || !oLinesEl) return 0;
const iOrigin = oLinesEl.getBoundingClientRect().left;
return this.getBoundaryX(oLineEl, iChars) - iOrigin;
},
/** Client x of the boundary `iChars` characters into a rendered line */
getBoundaryX(oLineEl, iChars) {
const oText = oLineEl.firstChild;
if(!oText || (iChars <= 0)) return oLineEl.getBoundingClientRect().left;
this.oRange.setStart(oText, 0);
this.oRange.setEnd(oText, Math.min(iChars, oText.length));
return this.oRange.getBoundingClientRect().right;
},
/**
* The input's own selection is invisible, so the book draws one rect per
* visual line the selection covers.
*/
buildSelection() {
const oOpen = this.openEntry;
if(!oOpen || (this.iSelectionStart >= this.iSelectionEnd)) return [];
const asRects = [];
const iFirstPage = this.iSpread * this.iPagesPerView;
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
const iPage = iFirstPage + iOffset;
const oPage = this.getPageComponent(iPage);
const oLinesEl = oPage?.getLinesElement();
if(!oLinesEl) continue;
const iOrigin = oLinesEl.getBoundingClientRect().left;
(this.pages[iPage] || []).forEach((oLine, iLine) => {
if(oLine.id !== oOpen.id) return;
const iFrom = Math.max(this.iSelectionStart, oLine.start);
const iTo = Math.min(this.iSelectionEnd, oLine.end);
if(iTo < iFrom) return;
const oLineEl = oPage.getLineElement(iLine);
if(!oLineEl) return;
const iLeft = this.getBoundaryX(oLineEl, iFrom - oLine.start) - iOrigin;
const iRight = this.getBoundaryX(oLineEl, iTo - oLine.start) - iOrigin;
asRects.push({
page: iPage,
line: iLine,
left: iLeft,
//A selected line break has no width of its own, but it
//was still selected - show it as a thin mark.
width: Math.max(3, iRight - iLeft)
});
});
}
return asRects;
},
caretFor(iPage) {
return (this.asCaret && (this.asCaret.page === iPage)) ? this.asCaret : null;
},
selectionFor(iPage) {
return this.asSelection.filter((oRect) => oRect.page === iPage);
},
placeholderFor(iPage) {
const oOpen = this.openEntry;
if(!oOpen || (oOpen.content !== '')) return -1;
const oPos = this.asLayout.index.get(oOpen.id);
return (oPos && (oPos.firstPage === iPage)) ? oPos.firstLineOnPage : -1;
},
isWritable(iPage) {
const oOpen = this.openEntry;
if(!oOpen) return false;
return (this.pages[iPage] || []).some((oLine) => oLine.id === oOpen.id);
},
/**
* The page component showing a given page of the book.
*
* Matched on the page it is actually displaying, not on its position in
* the ref array: Vue does not promise that a v-for ref array is in
* source order, and taking it on trust measures the caret against the
* facing page's text - which puts it somewhere else entirely on the line.
*/
getPageComponent(iPageIndex) {
const aoPages = this.$refs.pageEls;
if(!Array.isArray(aoPages)) return null;
return aoPages.find((oPage) => oPage && (oPage.pageIndex === iPageIndex)) || null;
},
/* Writing */
refresh() {
const oInput = this.$refs.input;
const oOpen = this.openEntry;
if(oInput && oOpen && (oInput.value !== oOpen.content)) {
oInput.value = oOpen.content;
oInput.setSelectionRange(oOpen.content.length, oOpen.content.length);
}
this.relayout();
this.$nextTick(() => this.syncCaret());
},
onInput(oEvent) {
this.journal.write(oEvent.target.value);
this.relayout();
this.syncCaret();
},
onSelectionChange() {
if(document.activeElement === this.$refs.input) this.syncCaret();
},
/**
* Mirror the input's caret onto the page - and, when the writing has run
* off the bottom of the spread, turn to the page it ran onto.
*/
syncCaret() {
const oInput = this.$refs.input;
if(!oInput) return;
this.iSelectionStart = oInput.selectionStart;
this.iSelectionEnd = oInput.selectionEnd;
this.iCaretOffset = (oInput.selectionDirection === 'backward') ? oInput.selectionStart : oInput.selectionEnd;
const oPos = this.caretPosition;
if(oPos && (oPos.spread !== this.iSpread)) this.setSpread(oPos.spread);
this.$nextTick(() => this.paintOverlay());
},
focusAt(iOffset, bExtend = false) {
const oInput = this.$refs.input;
if(!oInput) return;
oInput.focus({preventScroll: true});
if(bExtend) oInput.setSelectionRange(Math.min(oInput.selectionStart, iOffset), Math.max(oInput.selectionEnd, iOffset));
else oInput.setSelectionRange(iOffset, iOffset);
this.syncCaret();
},
/**
* Back to the page being written on.
*
* Following a bookmark re-centres the loaded window somewhere else in
* the book, which can leave the open entry outside it entirely - so the
* writing page has to be fetched back before it can be turned to.
*/
async goToWriting() {
let oOpen = this.openEntry;
if(!oOpen && (this.journal.openId > 0)) {
await this.journal.loadAround(this.journal.openId);
this.relayout();
await this.$nextTick();
oOpen = this.openEntry;
}
if(!oOpen) return;
this.focusAt((oOpen.content || '').length);
},
/** Clicking the paper puts the caret where you clicked */
onPick({pageIndex, lineIndex, clientX, extend}) {
const oOpen = this.openEntry;
if(!oOpen) return;
const asLines = this.pages[pageIndex] || [];
const oPage = this.getPageComponent(pageIndex);
if(!oPage) return;
let iLine = lineIndex;
let oLine = asLines[iLine];
let bExact = true;
//Clicking below the writing, or on an entry already closed, lands at
//the end of the nearest line that can actually be written on.
if(!oLine || (oLine.id !== oOpen.id)) {
bExact = false;
oLine = null;
for(let iAbove = Math.min(lineIndex, asLines.length - 1); iAbove >= 0; iAbove--) {
if(asLines[iAbove] && (asLines[iAbove].id === oOpen.id)) {
iLine = iAbove;
oLine = asLines[iAbove];
break;
}
}
}
if(!oLine) return;
const iOffset = bExact ? this.getOffsetAtX(oPage, iLine, oLine, clientX) : oLine.end;
this.focusAt(iOffset, extend);
},
/** Nearest character boundary to a click, by binary search over the line */
getOffsetAtX(oPage, iLine, oLine, iClientX) {
const oLineEl = oPage.getLineElement(iLine);
if(!oLineEl) return oLine.start;
const oText = oLineEl.firstChild;
const iLength = Math.min(oLine.end - oLine.start, oText ? oText.length : 0);
if(iLength <= 0) return oLine.start;
let iLow = 0;
let iHigh = iLength;
while(iLow < iHigh) {
const iMid = (iLow + iHigh) >> 1;
if(this.getBoundaryX(oLineEl, iMid) < iClientX) iLow = iMid + 1;
else iHigh = iMid;
}
//Land on whichever side of the character the click actually fell
if(iLow > 0) {
const iBefore = this.getBoundaryX(oLineEl, iLow - 1);
const iAfter = this.getBoundaryX(oLineEl, iLow);
if(Math.abs(iClientX - iBefore) < Math.abs(iClientX - iAfter)) iLow--;
}
return oLine.start + iLow;
},
/* Turning */
/**
* Turn to a spread, sending a leaf across the gutter to get there.
*
* A leaf has a page on each side, and the two it shows during the turn
* are not the two you end up looking at: going forward, the leaf lifts
* the old right-hand page and lands carrying the new left-hand one on
* its back. Going back it is the mirror of that.
*/
setSpread(iSpread, sDirection = '') {
const iNext = Math.max(0, Math.min(this.spreadCount - 1, iSpread));
if(iNext === this.iSpread) return;
const iFrom = this.iSpread;
const sDir = sDirection || ((iNext > iFrom) ? 'forward' : 'back');
const bForward = (sDir === 'forward');
//Keys the leaf element. Turning again before the last turn finished
//has to build a new leaf, or Vue patches the old one in place and
//the CSS animation carries on from wherever it had got to.
this.iTurnId++;
if(this.iPagesPerView > 1) {
this.asTurn = {
id: this.iTurnId,
dir: sDir,
from: iFrom,
//Front: the face you were reading. Back: what it reveals.
front: bForward ? ((iFrom * 2) + 1) : (iFrom * 2),
back: bForward ? (iNext * 2) : ((iNext * 2) + 1)
};
}
else this.asTurn = {id: this.iTurnId, dir: sDir, from: iFrom, front: -1, back: -1};
this.iSpread = iNext;
clearTimeout(this.oTurnTimer);
this.oTurnTimer = setTimeout(() => {
this.asTurn = null;
}, TURN_MS);
this.$nextTick(() => this.paintOverlay());
},
sideFor(iPage) {
return ((iPage % 2) === 0) ? 'left' : 'right';
},
async turn(iDelta) {
//Turning past either edge of the loaded window fetches more book
//before it turns, so the flow never dead-ends on a chunk boundary.
if((iDelta < 0) && (this.iSpread === 0)) {
if(this.journal.hasOlder) await this.extendBackwards();
return;
}
if((iDelta > 0) && (this.iSpread >= this.spreadCount - 1)) {
if(!this.journal.hasNewer) return;
await this.journal.loadNewer();
this.relayout();
await this.$nextTick();
this.setSpread(this.iSpread + 1, 'forward');
return;
}
this.setSpread(this.iSpread + iDelta);
},
/**
* Older entries are prepended, which shifts every page index - so the
* page being read is pinned first and restored after.
*/
async extendBackwards() {
const oAnchor = this.getAnchor();
const iAdded = await this.journal.loadOlder();
this.relayout();
await this.$nextTick();
if(oAnchor) this.restoreAnchor(oAnchor);
if(iAdded > 0) this.setSpread(this.iSpread - 1, 'back');
},
getAnchor() {
const oLine = (this.pages[this.iSpread * this.iPagesPerView] || [])[0];
return oLine ? {id: oLine.id, start: oLine.start} : null;
},
restoreAnchor(oAnchor) {
const iLine = this.flatLines.findIndex((oItem) => (oItem.id === oAnchor.id) && (oItem.start === oAnchor.start));
if(iLine < 0) return;
const iPage = Math.floor(iLine / Math.max(1, this.asLayout.linesPerPage));
this.iSpread = Math.floor(iPage / this.iPagesPerView);
},
/** Turn to an entry, fetching it first if it is outside the window */
async goToEntry(iEntryId) {
if(!this.asLayout.index.has(iEntryId)) {
await this.journal.loadAround(iEntryId);
this.relayout();
await this.$nextTick();
}
const oPos = this.asLayout.index.get(iEntryId);
if(!oPos) return;
this.setSpread(Math.floor(oPos.firstPage / this.iPagesPerView));
},
onKeydown(oEvent) {
//Only when the reader is not writing - otherwise these are just keys
if(this.bFocused) return;
if(oEvent.key === 'PageUp' || oEvent.key === 'ArrowLeft') this.turn(-1);
else if(oEvent.key === 'PageDown' || oEvent.key === 'ArrowRight') this.turn(1);
else return;
oEvent.preventDefault();
}
}
};
</script>
<template>
<div ref="book" class="book" tabindex="-1" @keydown="onKeydown">
<div class="book__shell">
<div
ref="spread"
class="book__spread"
:class="[
(iPagesPerView === 1) ? 'book__spread--single' : null,
asTurn ? 'book__turning' : null,
asTurn ? ('book__turning--' + asTurn.dir) : null
]"
>
<bookPage
v-for="oPage in visiblePages"
:key="oPage.index"
ref="pageEls"
:lines="oPage.lines"
:entries="entriesById"
:page-index="oPage.index"
:side="(oPage.index % 2 === 0) ? 'left' : 'right'"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
:writable="isWritable(oPage.index)"
:caret="caretFor(oPage.index)"
:selection="selectionFor(oPage.index)"
:placeholder-line="placeholderFor(oPage.index)"
:placeholder="lang.get('book.write_here')"
@pick="onPick"
/>
<!-- The leaf in flight. Two real pages back to back, hinged on
the gutter - the front is the page being lifted away, the
back is the one it carries into view. -->
<div
v-if="asTurn && (iPagesPerView > 1)"
:key="asTurn.id"
class="book__leaf"
:class="'book__leaf--' + asTurn.dir"
>
<div class="book__leaf-face book__leaf-face--front">
<bookPage
:lines="pages[asTurn.front] || []"
:entries="entriesById"
:page-index="asTurn.front"
:side="sideFor(asTurn.front)"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
/>
<span class="book__leaf-shade"></span>
</div>
<div class="book__leaf-face book__leaf-face--back">
<bookPage
:lines="pages[asTurn.back] || []"
:entries="entriesById"
:page-index="asTurn.back"
:side="sideFor(asTurn.back)"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
/>
<span class="book__leaf-shade"></span>
</div>
</div>
<span v-if="iPagesPerView > 1" class="book__spine"></span>
<span v-if="showRibbon" class="book__ribbon" style="height: 42%"></span>
<!-- Where the paginator does its measuring: one stable element,
laid out at the exact width of a real text column. -->
<div ref="measure" class="page__measure" :style="measureStyle" aria-hidden="true"></div>
<!-- The keystrokes, the selection and the arrow keys. Not the
glyphs - the pages draw those. -->
<textarea
ref="input"
class="page__input"
:style="inputStyle"
:readonly="!openEntry"
spellcheck="false"
autocapitalize="sentences"
:aria-label="lang.get('book.write_here')"
@input="onInput"
@keyup="syncCaret"
@focus="bFocused = true; paintOverlay()"
@blur="bFocused = false; paintOverlay()"
></textarea>
<button
type="button"
class="book__turner book__turner--back"
:disabled="!canTurnBack"
:title="lang.get('action.prev_page')"
@click="turn(-1)"
>
<appIcon icon="chevronLeft" size="1.4em" />
</button>
<button
type="button"
class="book__turner book__turner--forward"
:disabled="!canTurnForward"
:title="lang.get('action.next_page')"
@click="turn(1)"
>
<appIcon icon="chevronRight" size="1.4em" />
</button>
</div>
</div>
</div>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script>
import { formatDate, formatTime } from '@scripts/time';
/**
* One page of the book.
*
* A page is dumb on purpose: it is handed a list of already-broken visual lines
* and draws them, the stamps in its margin, and whatever overlay (caret,
* selection) the book has measured for it. All the deciding - where lines
* break, which page they land on, where the caret is - happens in Book.vue,
* because none of it can be decided a page at a time.
*/
export default {
props: {
//Visual lines for this page: {id, start, end, first}
lines: {type: Array, required: true},
//id -> entry, for the text and the margin stamps
entries: {type: Map, required: true},
//Position of this page in the whole book, for the page number
pageIndex: {type: Number, required: true},
side: {type: String, default: 'left'},
//The entry still being written, drawn a shade darker than past pages
openId: {type: Number, default: 0},
lineHeight: {type: Number, default: 0},
linesPerPage: {type: Number, default: 1},
//Set on the page that currently holds the writing caret
writable: {type: Boolean, default: false},
//{line, x} in page coordinates, or null
caret: {type: Object, default: null},
//[{line, left, width}] - the book draws its own selection, since the
//input's native one is invisible
selection: {type: Array, default: () => []},
//Line to hang the "write here" hint on, or -1
placeholderLine: {type: Number, default: -1},
placeholder: {type: String, default: ''}
},
emits: ['pick'],
inject: ['lang'],
computed: {
//Where an entry begins, the margin says when it was written
stamps() {
const asStamps = [];
this.lines.forEach((oLine, iIndex) => {
if(!oLine.first) return;
const oEntry = this.entries.get(oLine.id);
if(!oEntry) return;
asStamps.push({
id: oLine.id,
line: iIndex,
date: formatDate(oEntry.time, oEntry.timezone, this.lang.locale),
time: formatTime(oEntry.time, oEntry.timezone, this.lang.locale)
});
});
return asStamps;
},
pageNumber() {
return this.pageIndex + 1;
}
},
methods: {
lineText(oLine) {
const oEntry = this.entries.get(oLine.id);
return oEntry ? oEntry.content.slice(oLine.start, oLine.end) : '';
},
isOpenLine(oLine) {
return (oLine.id === this.openId);
},
offsetFor(iLine) {
return (iLine * this.lineHeight) + 'px';
},
/* Read back by Book.vue, which needs real geometry to place the caret,
* the selection, the input and the measurer. */
getLinesElement() {
return this.$refs.lines || null;
},
getBodyElement() {
return this.$refs.body || null;
},
/**
* The nth rendered line.
*
* Queried from the DOM rather than read out of a `ref` on the v-for:
* Vue makes no promise that a v-for ref array is in source order, and
* when it is not, the caret gets measured against the wrong line - which
* lands it back at the start of the line instead of after what was
* just typed.
*/
getLineElement(iIndex) {
const oLines = this.$refs.lines;
if(!oLines) return null;
return oLines.querySelectorAll('.page__line')[iIndex] || null;
},
onClick(oEvent) {
if(!this.writable || (this.lineHeight <= 0)) return;
const oLines = this.$refs.lines;
if(!oLines) return;
const oRect = oLines.getBoundingClientRect();
const iLine = Math.floor((oEvent.clientY - oRect.top) / this.lineHeight);
this.$emit('pick', {
pageIndex: this.pageIndex,
lineIndex: Math.max(0, Math.min(this.linesPerPage - 1, iLine)),
clientX: oEvent.clientX,
extend: oEvent.shiftKey
});
}
}
};
</script>
<template>
<section
class="page"
:class="['page--' + side, writable ? 'page--writable' : null]"
@mousedown.prevent="onClick"
>
<div ref="body" class="page__body">
<div class="page__margin">
<span
v-for="oStamp in stamps"
:key="oStamp.id"
class="page__stamp"
:style="{top: offsetFor(oStamp.line)}"
>
<span class="page__stamp-date">{{ oStamp.date }}</span>
<span class="page__stamp-time">{{ oStamp.time }}</span>
</span>
</div>
<div class="page__column">
<div ref="lines" class="page__lines">
<span
v-for="(oSelection, iIndex) in selection"
:key="'sel' + iIndex"
class="page__selection"
:style="{top: offsetFor(oSelection.line), left: oSelection.left + 'px', width: oSelection.width + 'px'}"
></span>
<span
v-for="(oLine, iIndex) in lines"
:key="iIndex"
class="page__line"
:class="isOpenLine(oLine) ? null : 'page__line--closed'"
>{{ lineText(oLine) }}</span>
<span
v-if="placeholderLine >= 0"
class="page__placeholder"
:style="{top: offsetFor(placeholderLine)}"
>{{ placeholder }}</span>
<span
v-if="caret"
class="page__caret"
:style="{top: offsetFor(caret.line), left: caret.x + 'px'}"
></span>
</div>
</div>
</div>
<div class="page__foot">
<span class="page__number">{{ pageNumber }}</span>
</div>
</section>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script>
import appIcon from '@components/AppIcon';
import { formatShortDate, formatTime } from '@scripts/time';
//Bookmarks shown either side of the entry being read. A whole book's worth of
//tabs is a scrollbar, not a rail - a handful around where you are is something
//you can actually aim at, and turning pages walks the window along.
const RAIL_REACH = 3;
/**
* The tabs down the side of the book: one per entry, in writing order.
*
* Each is stamped with the date and time the entry was started, in the timezone
* it was written in - so a book kept across a move still reads as the local
* time of each moment. Clicking one turns the book to that page.
*/
export default {
components: {
appIcon
},
props: {
bookmarks: {type: Array, required: true},
//The entry the visible spread belongs to
currentId: {type: Number, default: 0},
//The entry still being written
openId: {type: Number, default: 0},
hasOlder: {type: Boolean, default: false},
loading: {type: Boolean, default: false}
},
emits: ['pick', 'load-older'],
inject: ['lang'],
computed: {
tabs() {
return this.bookmarks.map((oBookmark) => ({
id: oBookmark.id,
date: formatShortDate(oBookmark.time, oBookmark.timezone, this.lang.locale),
time: formatTime(oBookmark.time, oBookmark.timezone, this.lang.locale),
preview: oBookmark.preview || this.lang.get('book.empty_entry'),
open: (oBookmark.status === 'open')
}));
},
/**
* The three either side of where the reading is.
*
* Near the ends of the book there are simply fewer - the window is not
* padded back to a fixed size, because a tab that is not next to where
* you are is not what "next" means.
*/
shown() {
const iCurrent = this.tabs.findIndex((oTab) => oTab.id === this.currentId);
const iAnchor = (iCurrent >= 0) ? iCurrent : (this.tabs.length - 1);
return this.tabs.slice(
Math.max(0, iAnchor - RAIL_REACH),
Math.min(this.tabs.length, iAnchor + RAIL_REACH + 1)
);
},
//Whether the window itself has more book beyond it, in either direction
hasBefore() {
return (this.shown.length > 0) && (this.shown[0].id !== this.tabs[0]?.id);
},
hasAfter() {
return (this.shown.length > 0) && (this.shown.at(-1).id !== this.tabs.at(-1)?.id);
}
},
watch: {
currentId() {
this.$nextTick(() => this.scrollToCurrent());
}
},
mounted() {
//The rail is chronological, so the page being written is at the bottom.
this.$nextTick(() => this.scrollToCurrent());
},
methods: {
scrollToCurrent() {
const oList = this.$refs.list;
if(!oList) return;
const oTab = oList.querySelector('.rail__tab--current');
if(oTab) oTab.scrollIntoView({block: 'nearest'});
else oList.scrollTop = oList.scrollHeight;
}
}
};
</script>
<template>
<nav class="rail" :aria-label="lang.get('book.bookmarks')">
<span class="rail__title">{{ lang.get('book.bookmarks') }}</span>
<!-- There is more book above the window, or older entries still to load -->
<button
v-if="hasBefore || hasOlder"
type="button"
class="rail__more"
:disabled="loading"
:title="lang.get('action.prev_page')"
@click="$emit('load-older')"
>
<appIcon icon="chevronLeft" size="0.8em" />
</button>
<div ref="list" class="rail__list">
<button
v-for="oTab in shown"
:key="oTab.id"
type="button"
class="rail__tab"
:class="{
'rail__tab--current': (oTab.id === currentId),
'rail__tab--open': (oTab.id === openId)
}"
@click="$emit('pick', oTab.id)"
>
<span class="rail__date">
<span class="rail__day">{{ oTab.date }}</span>
<span class="rail__time">{{ oTab.time }}</span>
</span>
<span class="rail__preview">{{ oTab.preview }}</span>
</button>
</div>
<span v-if="tabs.length === 0" class="rail__empty">{{ lang.get('book.first_page') }}</span>
</nav>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script>
import appIcon from '@components/AppIcon';
import { formatMonthTitle, getLocalDayKey, getWeekdayInitials } from '@scripts/time';
/**
* A month at a time, with a mark under every day that was written on.
*
* The marks come from the bookmark list, which the journal already holds in
* full - so this needs no request of its own, and doubles as a picture of how
* regularly the book is being kept.
*/
export default {
components: {
appIcon
},
props: {
//Map of 'YYYY-MM-DD' to number of entries started that day
days: {type: Map, required: true},
//The day the book is currently open at
currentDay: {type: String, default: ''}
},
emits: ['pick'],
inject: ['lang'],
data() {
return {
//Opens on the month being read, or on this month
oMonth: this.getMonthStart(this.currentDay)
};
},
computed: {
monthTitle() {
return formatMonthTitle(this.oMonth, this.lang.locale);
},
weekdays() {
return getWeekdayInitials(this.lang.locale);
},
today() {
return getLocalDayKey(new Date());
},
//Six full weeks, always - a grid that changes height as you page
//through months is far more distracting than one blank row.
cells() {
const oFirst = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth(), 1);
//getDay() is Sunday-first; the grid is Monday-first
const iLead = (oFirst.getDay() + 6) % 7;
const asCells = [];
for(let iCell = 0; iCell < 42; iCell++) {
const oDate = new Date(oFirst.getFullYear(), oFirst.getMonth(), 1 - iLead + iCell);
const sKey = getLocalDayKey(oDate);
asCells.push({
key: sKey,
day: oDate.getDate(),
outside: (oDate.getMonth() !== this.oMonth.getMonth()),
future: (sKey > this.today),
count: this.days.get(sKey) || 0
});
}
return asCells;
},
monthCount() {
return this.cells.reduce((iTotal, oCell) => iTotal + (oCell.outside ? 0 : oCell.count), 0);
},
//Nothing was ever written in the future, so there is nowhere to go
atLastMonth() {
const oNow = new Date();
return (this.oMonth.getFullYear() === oNow.getFullYear()) && (this.oMonth.getMonth() === oNow.getMonth());
}
},
watch: {
currentDay(sDay) {
if(sDay !== '') this.oMonth = this.getMonthStart(sDay);
}
},
methods: {
getMonthStart(sDay) {
//'YYYY-MM-DD' split by hand: new Date('2026-03-01') is parsed as UTC
//and can land in the previous month west of Greenwich.
const asParts = (sDay || '').split('-');
if(asParts.length === 3) return new Date(Number(asParts[0]), Number(asParts[1]) - 1, 1);
const oNow = new Date();
return new Date(oNow.getFullYear(), oNow.getMonth(), 1);
},
step(iMonths) {
this.oMonth = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth() + iMonths, 1);
},
goToToday() {
const oNow = new Date();
this.oMonth = new Date(oNow.getFullYear(), oNow.getMonth(), 1);
this.$emit('pick', this.today);
},
pick(oCell) {
this.$emit('pick', oCell.key);
}
}
};
</script>
<template>
<div class="calendar">
<div class="calendar__head">
<button
type="button"
class="calendar__step"
:title="lang.get('action.prev_page')"
@click="step(-1)"
>
<appIcon icon="chevronLeft" />
</button>
<span class="calendar__month">{{ monthTitle }}</span>
<button
type="button"
class="calendar__step"
:disabled="atLastMonth"
:title="lang.get('action.next_page')"
@click="step(1)"
>
<appIcon icon="chevronRight" />
</button>
</div>
<div class="calendar__grid">
<span v-for="sDay in weekdays" :key="sDay" class="calendar__weekday">{{ sDay }}</span>
<button
v-for="oCell in cells"
:key="oCell.key"
type="button"
class="calendar__day"
:class="{
'calendar__day--outside': oCell.outside,
'calendar__day--written': (oCell.count > 0),
'calendar__day--today': (oCell.key === today),
'calendar__day--current': (oCell.key === currentDay)
}"
:disabled="oCell.future"
@click="pick(oCell)"
>
{{ oCell.day }}
</button>
</div>
<div class="calendar__foot">
<button type="button" class="calendar__link" @click="goToToday">
{{ lang.get('book.today') }}
</button>
<span class="calendar__count">{{ monthCount }} {{ lang.get('book.entries').toLowerCase() }}</span>
</div>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script>
import appIcon from '@components/AppIcon';
import { SAVE_FAILED, SAVE_IDLE, SAVE_PENDING, SAVE_SAVED, SAVE_SAVING } from '@scripts/journal';
import { formatSince } from '@scripts/time';
/**
* Ambient reassurance that the writing is landing somewhere.
*
* Deliberately quiet: autosave is the normal case, so the only state allowed to
* draw the eye is the one that needs a decision - a save that failed, which
* becomes a button that retries it.
*/
export default {
components: {
appIcon
},
inject: ['journal', 'lang'],
data() {
return {
//Bumped on an interval purely so the "saved 3 minutes ago" stamp
//keeps up without the journal having to emit anything.
iTick: 0
};
},
computed: {
state() {
return this.journal.saveState;
},
visible() {
return (this.state !== SAVE_IDLE) || (this.journal.savedAt > 0);
},
failed() {
return (this.state === SAVE_FAILED);
},
label() {
switch(this.state) {
case SAVE_SAVING:
return this.lang.get('save.saving');
case SAVE_PENDING:
return this.lang.get('save.pending');
case SAVE_FAILED:
return this.lang.get('save.failed');
default:
return (this.journal.savedAt > 0) ? this.lang.get('save.saved', this.since) : '';
}
},
since() {
//Reading iTick is what subscribes this to the interval.
return (this.iTick >= 0) ? formatSince(this.journal.savedAt, this.lang.locale, this.lang.get('save.just_now')) : '';
},
icon() {
if(this.failed) return 'alert';
return (this.state === SAVE_SAVED) ? 'check' : '';
},
classes() {
return {
'saver--saving': (this.state === SAVE_SAVING),
'saver--pending': (this.state === SAVE_PENDING),
'saver--failed': this.failed
};
}
},
mounted() {
this.oTicker = setInterval(() => this.iTick++, 30000);
},
beforeUnmount() {
clearInterval(this.oTicker);
},
methods: {
retry() {
if(this.failed) this.journal.save();
}
}
};
</script>
<template>
<component
:is="failed ? 'button' : 'span'"
v-if="visible"
class="saver"
:class="classes"
:type="failed ? 'button' : null"
:title="failed ? journal.error : null"
@click="retry"
>
<appIcon v-if="icon" :icon="icon" size="0.95em" />
<span v-else class="saver__dot"></span>
<span>{{ label }}</span>
</component>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script>
import appIcon from '@components/AppIcon';
/**
* Account settings, saved one field at a time.
*
* There is no Save button on purpose: each field commits on change, the same
* way the book itself autosaves. A language change is the one setting that
* needs the page back to pick up the new dictionary.
*/
export default {
components: {
appIcon
},
emits: ['close', 'signed-out'],
inject: ['api', 'consts', 'lang', 'user'],
data() {
return {
sName: this.user.name,
sError: '',
sSaved: '',
bBusy: false
};
},
computed: {
languages() {
return this.consts.languages || ['en'];
},
//Intl knows the zone list; older engines only know the one in use.
timezones() {
try {
return Intl.supportedValuesOf('timeZone');
}
catch{
return [this.user.timezone].filter((sZone) => sZone !== '');
}
},
languageNames() {
const oNames = new Intl.DisplayNames([this.lang.locale], {type: 'language'});
return this.languages.map((sCode) => ({
code: sCode,
label: oNames.of(sCode) || sCode
}));
}
},
methods: {
async set(sField, sValue) {
this.bBusy = true;
this.sError = '';
this.sSaved = '';
try {
const asData = await this.api.post('account', {field: sField, value: sValue});
Object.assign(this.user, asData.user);
this.sSaved = sField;
//The dictionary is baked into the page, so a new language means
//a new page - and nothing is lost, the book is already saved.
if(sField === 'language') window.location.reload();
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
this.sName = this.user.name;
}
finally {
this.bBusy = false;
}
},
saveName() {
const sName = this.sName.trim();
if((sName !== '') && (sName !== this.user.name)) this.set('name', sName);
},
async signOut() {
this.bBusy = true;
try {
await this.api.post('logout');
this.$emit('signed-out');
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message;
}
finally {
this.bBusy = false;
}
}
}
};
</script>
<template>
<div class="veil" @click.self="$emit('close')">
<div class="leaf leaf--wide" role="dialog" aria-modal="true">
<div class="leaf__head">
<h2 class="leaf__title">{{ lang.get('account.settings') }}</h2>
<p class="leaf__sub">{{ user.email }}</p>
</div>
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
<div class="leaf__row">
<label class="paper-label" for="set-name">{{ lang.get('account.name') }}</label>
<input
id="set-name"
v-model="sName"
class="paper-field"
type="text"
maxlength="100"
:disabled="bBusy"
@change="saveName"
@keyup.enter="saveName"
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="set-lang">{{ lang.get('account.language') }}</label>
<select
id="set-lang"
class="paper-field"
:value="user.language"
:disabled="bBusy"
@change="set('language', $event.target.value)"
>
<option v-for="oLanguage in languageNames" :key="oLanguage.code" :value="oLanguage.code">
{{ oLanguage.label }}
</option>
</select>
</div>
<div class="leaf__row">
<label class="paper-label" for="set-tz">{{ lang.get('account.timezone') }}</label>
<select
id="set-tz"
class="paper-field"
:value="user.timezone"
:disabled="bBusy"
@change="set('timezone', $event.target.value)"
>
<option v-for="sZone in timezones" :key="sZone" :value="sZone">{{ sZone }}</option>
</select>
</div>
<div class="leaf__actions">
<button type="button" class="ink-button" @click="$emit('close')">
<appIcon icon="check" />
<span>{{ lang.get('action.close') }}</span>
</button>
<button type="button" class="text-button text-button--bad" :disabled="bBusy" @click="signOut">
<span>{{ lang.get('account.sign_out') }}</span>
</button>
</div>
</div>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="MyThoughts">
<title>MyThoughts</title>
<defs>
<linearGradient id="desk" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#44321e"/>
<stop offset="0.55" stop-color="#2c2114"/>
<stop offset="1" stop-color="#1a140d"/>
</linearGradient>
</defs>
<!-- Touch icons are masked by the OS, so the artwork keeps clear of the edges -->
<rect width="180" height="180" fill="url(#desk)"/>
<g transform="translate(90 92) scale(2.55) translate(-32 -33)">
<path d="M32 19.5C27.7 15.9 22.2 14.2 14.5 14.2v32.4c7.7 0 13.2 1.7 17.5 5.2 4.3-3.5 9.8-5.2 17.5-5.2V14.2c-7.7 0-13.2 1.7-17.5 5.3z"
fill="#f8f2e4" stroke="#cdbb99" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M32 19.5v32.3" stroke="#cdbb99" stroke-width="2" stroke-linecap="round"/>
<g stroke="#6d6358" stroke-width="1.7" stroke-linecap="round" opacity="0.75">
<path d="M20.5 26.5h7.5"/>
<path d="M20.5 32h7.5"/>
<path d="M20.5 37.5h5"/>
</g>
<path d="M36.5 14.6h6v16l-3-2.4-3 2.4z" fill="#795731"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="MyThoughts">
<title>MyThoughts</title>
<rect width="64" height="64" rx="12" fill="#2c2114"/>
<!-- The two pages, splayed from the gutter -->
<path d="M32 19.5C27.7 15.9 22.2 14.2 14.5 14.2v32.4c7.7 0 13.2 1.7 17.5 5.2 4.3-3.5 9.8-5.2 17.5-5.2V14.2c-7.7 0-13.2 1.7-17.5 5.3z"
fill="#f8f2e4" stroke="#cdbb99" stroke-width="1.6" stroke-linejoin="round"/>
<!-- The gutter -->
<path d="M32 19.5v32.3" stroke="#cdbb99" stroke-width="2" stroke-linecap="round"/>
<!-- Writing on the left page, trailing off the way a page in progress does -->
<g stroke="#6d6358" stroke-width="1.7" stroke-linecap="round" opacity="0.75">
<path d="M20.5 26.5h7.5"/>
<path d="M20.5 32h7.5"/>
<path d="M20.5 37.5h5"/>
</g>
<!-- The ribbon, marking today's page -->
<path d="M36.5 14.6h6v16l-3-2.4-3 2.4z" fill="#795731"/>
</svg>

After

Width:  |  Height:  |  Size: 903 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "MyThoughts",
"short_name": "MyThoughts",
"description": "A quiet place to write. Your thoughts, on paper.",
"start_url": "../../../",
"scope": "../../../",
"display": "standalone",
"orientation": "any",
"background_color": "#2c2114",
"theme_color": "#2c2114",
"icons": [
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml"
},
{
"src": "apple-touch-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 55 KiB

+86
View File
@@ -0,0 +1,86 @@
export default class Api {
constructor({server, processPage, timezone, csrfToken, errorCode, lang}) {
this.server = server;
this.processPage = processPage;
this.timezone = timezone;
this.csrfToken = csrfToken;
this.errorCode = errorCode;
this.lang = lang;
}
async get(sAction, asParams = {}) {
const oResponse = await this.request(sAction, asParams, 'GET');
return oResponse.data;
}
async post(sAction, asParams = {}) {
const oResponse = await this.request(sAction, asParams, 'POST');
return oResponse.data;
}
/**
* Fire-and-forget POST that survives the page going away. Used for the
* close-the-entry call on unload, where a fetch would simply be cancelled.
* sendBeacon cannot set headers, so the CSRF token rides in the body -
* which the controller accepts as a fallback for exactly this case.
*/
beacon(sAction, asParams = {}) {
if(!navigator.sendBeacon) return false;
const oBody = new URLSearchParams({
...asParams,
a: sAction,
t: this.timezone,
csrf_token: this.csrfToken
});
return navigator.sendBeacon(
new URL(this.processPage, this.server),
new Blob([oBody.toString()], {type: 'application/x-www-form-urlencoded;charset=UTF-8'})
);
}
async request(sAction, asParams = {}, sMethod = 'GET') {
const oUrl = new URL(this.processPage, this.server);
const sUrlParams = new URLSearchParams({
...asParams,
a: sAction,
t: this.timezone
}).toString();
const asOptions = {
method: sMethod,
headers: {'Accept': 'application/json'}
};
if(sMethod === 'GET') {
oUrl.search = sUrlParams;
}
else {
asOptions.headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
asOptions.headers['X-CSRF-Token'] = this.csrfToken;
asOptions.body = sUrlParams;
}
const oRequest = await fetch(oUrl, asOptions);
if(!oRequest.ok) {
throw new Error('Error HTTP ' + oRequest.status + ': ' + oRequest.statusText);
}
const oResponse = await oRequest.json();
oResponse.desc_lang_text = this.lang.get(oResponse.desc_lang_id, oResponse.desc_lang_params);
if(oResponse.result == this.errorCode) {
const oError = new Error(oResponse.desc_lang_text || oResponse.desc_lang_id);
oError.desc_lang_id = oResponse.desc_lang_id;
oError.desc_lang_params = oResponse.desc_lang_params;
oError.desc_lang_text = oResponse.desc_lang_text;
throw oError;
}
return oResponse;
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* The icon set, as raw path data on a 24x24 grid.
*
* Kept as data rather than as files so the icons ship inside the JS bundle and
* inherit `currentColor` - which matters here because the same glyph appears
* both on paper (dark ink) and on the desk (warm white), and an <img> could do
* neither. Everything is stroked, nothing is filled, so they sit next to a
* handwriting font without looking like UI chrome.
*/
const asIcons = {
//An open book, seen from above - the brand mark
book: [
'M12 6.6C10.1 5.1 7.7 4.4 4.4 4.4v12.9c3.3 0 5.7.7 7.6 2.3 1.9-1.6 4.3-2.3 7.6-2.3V4.4c-3.3 0-5.7.7-7.6 2.2z',
'M12 6.6v12.9'
],
calendar: [
'M4.6 6.9h14.8v12.5H4.6z',
'M4.6 10.6h14.8',
'M8.5 4.6v3.6',
'M15.5 4.6v3.6'
],
//Calendar with the day marked: "jump to today"
today: [
'M4.6 6.9h14.8v12.5H4.6z',
'M4.6 10.6h14.8',
'M8.5 4.6v3.6',
'M15.5 4.6v3.6',
'M11.9 14.6h.2'
],
chevronLeft: ['M14.6 5.4 8 12l6.6 6.6'],
chevronRight: ['M9.4 5.4 16 12l-6.6 6.6'],
bookmark: ['M6.8 4.6h10.4v15.3L12 16.2l-5.2 3.7z'],
pen: [
'M4.8 19.2l1-3.7L15.4 6l2.7 2.7-9.6 9.5z',
'M13.6 7.8l2.7 2.7'
],
user: [
'M12 11.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2z',
'M4.8 20c.6-3.7 3.5-5.8 7.2-5.8s6.6 2.1 7.2 5.8'
],
settings: [
'M12 15.2a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z',
'M12 3v2.3',
'M12 18.7V21',
'M3 12h2.3',
'M18.7 12H21',
'M5.6 5.6l1.7 1.7',
'M16.7 16.7l1.7 1.7',
'M18.4 5.6l-1.7 1.7',
'M7.3 16.7l-1.7 1.7'
],
signOut: [
'M14.4 8V5.2H5.6v13.6h8.8V16',
'M10.2 12h9.4',
'M16.9 9.2 19.6 12l-2.7 2.8'
],
trash: [
'M5.6 7.5h12.8',
'M9.5 7.5V5.2h5v2.3',
'M7 7.5l.9 12.3h8.2L17 7.5',
'M10.5 10.9v5.7',
'M13.5 10.9v5.7'
],
close: ['M6.6 6.6l10.8 10.8', 'M17.4 6.6 6.6 17.4'],
check: ['M5.6 12.7l4.2 4.2 8.6-9.6'],
alert: [
'M12 4.7 3.3 19.3h17.4z',
'M12 9.7v4.4',
'M11.9 16.7h.2'
]
};
export function getIconPaths(sName) {
return asIcons[sName] || [];
}
export function hasIcon(sName) {
return Object.prototype.hasOwnProperty.call(asIcons, sName);
}
+286
View File
@@ -0,0 +1,286 @@
import { getDayKey } from '@scripts/time';
export const SAVE_IDLE = 'idle';
export const SAVE_PENDING = 'pending';
export const SAVE_SAVING = 'saving';
export const SAVE_SAVED = 'saved';
export const SAVE_FAILED = 'failed';
/**
* The loaded stretch of the journal, plus the entry currently being written.
*
* The book is one continuous stream, but a long journal is not something to
* ship in a single response - so this keeps a moving window of entries and
* extends it when the reader turns past either edge. Bookmarks are the one
* thing held in full: they are id-and-timestamp only, and both the rail and
* the calendar are built from them.
*/
export default class Journal {
constructor(oApi, asConsts) {
this.api = oApi;
this.consts = asConsts;
this.entries = [];
this.bookmarks = [];
this.hasOlder = false;
this.hasNewer = false;
this.openId = 0;
this.loading = false;
this.error = '';
this.saveState = SAVE_IDLE;
this.savedAt = 0;
this.savedContent = '';
this.oSaveTimer = null;
this.oMaxWaitTimer = null;
}
/* Reading */
get openEntry() {
return this.entries.find((oEntry) => oEntry.id === this.openId) || null;
}
/** True when the loaded window reaches the end of the book. */
get atWritingEnd() {
return !this.hasNewer && (this.openId > 0) && (this.entries.at(-1)?.id === this.openId);
}
get days() {
const asDays = new Map();
for(const oBookmark of this.bookmarks) {
const sDay = getDayKey(oBookmark.time, oBookmark.timezone);
asDays.set(sDay, (asDays.get(sDay) || 0) + 1);
}
return asDays;
}
async load() {
this.loading = true;
try {
const asData = await this.api.get('book');
this.entries = asData.entries;
this.bookmarks = asData.bookmarks;
this.hasOlder = asData.has_older;
this.hasNewer = asData.has_newer;
//A "book" response is the newest window, so an entry still marked
//open in it is the one this session continues writing into.
const oOpen = this.entries.find((oEntry) => oEntry.status === 'open');
this.openId = oOpen ? oOpen.id : 0;
this.savedContent = oOpen ? oOpen.content : '';
this.error = '';
}
finally {
this.loading = false;
}
}
async loadOlder() {
if(!this.hasOlder || this.loading) return 0;
const iCursorId = this.entries[0]?.id || 0;
if(iCursorId === 0) return 0;
this.loading = true;
try {
const asData = await this.api.get('entries', {dir: 'before', id: iCursorId});
this.entries = [...asData.entries, ...this.entries];
this.hasOlder = asData.has_older;
return asData.entries.length;
}
finally {
this.loading = false;
}
}
async loadNewer() {
if(!this.hasNewer || this.loading) return 0;
const iCursorId = this.entries.at(-1)?.id || 0;
if(iCursorId === 0) return 0;
this.loading = true;
try {
const asData = await this.api.get('entries', {dir: 'after', id: iCursorId});
this.entries = [...this.entries, ...asData.entries];
this.hasNewer = asData.has_newer;
return asData.entries.length;
}
finally {
this.loading = false;
}
}
/** Re-centre the window on an entry that may be far outside it. */
async loadAround(iEntryId) {
if(this.entries.some((oEntry) => oEntry.id === iEntryId)) return true;
this.loading = true;
try {
const asData = await this.api.get('entries', {dir: 'around', id: iEntryId});
this.entries = asData.entries;
this.hasOlder = asData.has_older;
this.hasNewer = asData.has_newer;
return this.entries.some((oEntry) => oEntry.id === iEntryId);
}
finally {
this.loading = false;
}
}
/** @returns id of the entry written closest to that day, or 0 */
async findEntryAtDate(sDate) {
const asData = await this.api.get('date', {date: sDate});
return asData.id || 0;
}
/* Writing */
/**
* Claim the entry this session writes into. Resuming a still-open entry
* (a reload, a second tab) continues it instead of splitting the thought.
*/
async startWriting() {
const asData = await this.api.post('open_entry');
const oEntry = asData.entry;
if(!oEntry) return null;
this.openId = oEntry.id;
this.savedContent = oEntry.content;
if(!this.entries.some((oExisting) => oExisting.id === oEntry.id)) {
this.entries.push(oEntry);
this.hasNewer = false;
}
if(!this.bookmarks.some((oBookmark) => oBookmark.id === oEntry.id)) {
this.bookmarks.push({
id: oEntry.id,
time: oEntry.time,
timezone: oEntry.timezone,
status: oEntry.status,
preview: ''
});
}
return oEntry;
}
write(sContent) {
const oEntry = this.openEntry;
if(!oEntry) return;
oEntry.content = sContent;
const oBookmark = this.bookmarks.find((oItem) => oItem.id === oEntry.id);
if(oBookmark) oBookmark.preview = sContent.replace(/\s+/g, ' ').trim().slice(0, 60);
this.scheduleSave();
}
scheduleSave() {
this.saveState = SAVE_PENDING;
clearTimeout(this.oSaveTimer);
this.oSaveTimer = setTimeout(() => this.save(), this.consts.autosave_delay);
//Someone writing without pause would otherwise never trip the debounce.
if(!this.oMaxWaitTimer) {
this.oMaxWaitTimer = setTimeout(() => this.save(), this.consts.autosave_max_wait);
}
}
get isDirty() {
const oEntry = this.openEntry;
return (oEntry !== null) && (oEntry.content !== this.savedContent);
}
async save() {
this.clearTimers();
const oEntry = this.openEntry;
if(!oEntry || !this.isDirty) {
if(this.saveState === SAVE_PENDING) this.saveState = SAVE_SAVED;
return;
}
const sContent = oEntry.content;
this.saveState = SAVE_SAVING;
try {
const asData = await this.api.post('save_entry', {id: oEntry.id, content: sContent});
this.savedContent = sContent;
this.savedAt = asData.saved_at || Math.round(Date.now() / 1000);
this.error = '';
//Keystrokes that landed mid-request are still unsaved.
this.saveState = this.isDirty ? SAVE_PENDING : SAVE_SAVED;
if(this.saveState === SAVE_PENDING) this.scheduleSave();
}
catch(oError) {
this.saveState = SAVE_FAILED;
this.error = oError.desc_lang_text || oError.message;
}
}
/**
* Seal the entry as the page goes away. This has to be a beacon: a fetch
* issued during unload is cancelled with the document. The content rides
* along so the last keystrokes land even if the debounce never fired.
*/
closeOnUnload() {
const oEntry = this.openEntry;
if(!oEntry) return;
this.clearTimers();
this.api.beacon('close_entry', {id: oEntry.id, content: oEntry.content});
}
/** Explicit close - used when signing out, where a response is wanted. */
async closeEntry() {
const oEntry = this.openEntry;
if(!oEntry) return;
this.clearTimers();
const iEntryId = oEntry.id;
this.openId = 0;
try {
const asData = await this.api.post('close_entry', {id: iEntryId, content: oEntry.content});
if(asData.discarded) {
this.entries = this.entries.filter((oItem) => oItem.id !== iEntryId);
this.bookmarks = this.bookmarks.filter((oItem) => oItem.id !== iEntryId);
}
else {
const oBookmark = this.bookmarks.find((oItem) => oItem.id === iEntryId);
if(oBookmark) oBookmark.status = 'closed';
if(asData.entry) Object.assign(oEntry, asData.entry);
}
}
catch{
//Closing is best-effort; the server seals stale entries anyway.
}
}
async deleteEntry(iEntryId) {
await this.api.post('delete_entry', {id: iEntryId});
this.entries = this.entries.filter((oItem) => oItem.id !== iEntryId);
this.bookmarks = this.bookmarks.filter((oItem) => oItem.id !== iEntryId);
if(this.openId === iEntryId) this.openId = 0;
}
clearTimers() {
clearTimeout(this.oSaveTimer);
clearTimeout(this.oMaxWaitTimer);
this.oSaveTimer = null;
this.oMaxWaitTimer = null;
}
}
+25
View File
@@ -0,0 +1,25 @@
export default class Lang {
constructor({translations = {}, prefix = '', locale = 'en'} = {}) {
this.translations = translations;
this.prefix = prefix;
this.locale = locale;
}
get(sLangId = '', params = []) {
if(sLangId === '') return '';
const asParams = Array.isArray(params) ? params : [params];
if(Object.prototype.hasOwnProperty.call(this.translations, sLangId)) {
let sText = this.translations[sLangId];
asParams.forEach((sParam, iIndex) => {
sText = sText.replace('$' + iIndex, sParam);
});
return sText;
}
console.warn('Missing translation:', sLangId);
return sLangId;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* Flows the journal into book pages.
*
* The server stores flat text; where a line breaks and where a page ends
* depends entirely on the reader's viewport, so the split happens here. Rather
* than guess at wrapping with canvas text metrics - which drift from what the
* browser actually does - this measures a real element laid out by the browser
* and reads the break positions back out of it with Range. The measurer lives
* inside the page column itself, so it inherits the exact width, font and
* letter-spacing the finished lines will use, and the textarea that sits on the
* writing page wraps identically for free.
*/
export default class Paginator {
constructor() {
this.oMeasurer = null;
this.iLineHeight = 0;
this.iLinesPerPage = 0;
this.sSignature = '';
this.asCache = new Map();
this.oRange = document.createRange();
}
get ready() {
return (this.oMeasurer !== null) && (this.iLineHeight > 0) && (this.iLinesPerPage > 0);
}
/**
* @param oMeasurer Hidden element inside the page text column
* @param iLineHeight Height of one ruled line, in px
* @param iLinesPerPage How many ruled lines a page holds
*/
setMetrics(oMeasurer, iLineHeight, iLinesPerPage) {
const sSignature = [oMeasurer?.clientWidth || 0, iLineHeight, iLinesPerPage].join(':');
if(sSignature === this.sSignature && oMeasurer === this.oMeasurer) return false;
this.oMeasurer = oMeasurer;
this.iLineHeight = iLineHeight;
this.iLinesPerPage = iLinesPerPage;
this.sSignature = sSignature;
//Every cached wrap was measured at the old width - none of it survives.
this.asCache.clear();
return true;
}
/* Layout */
/**
* @param aoEntries Entries in reading order
* @returns {{pages: Array, index: Map, lineCount: number}}
*/
layout(aoEntries) {
const asLines = [];
const asIndex = new Map();
for(const oEntry of aoEntries) {
const asEntryLines = this.getEntryLines(oEntry);
asIndex.set(oEntry.id, {
firstLine: asLines.length,
lastLine: asLines.length + asEntryLines.length - 1
});
asEntryLines.forEach((asLine, iIndex) => {
asLines.push({
id: oEntry.id,
start: asLine.start,
end: asLine.end,
first: (iIndex === 0)
});
});
}
const iPerPage = Math.max(1, this.iLinesPerPage);
const aoPages = [];
for(let iLine = 0; iLine < asLines.length; iLine += iPerPage) {
aoPages.push(asLines.slice(iLine, iLine + iPerPage));
}
//An empty book is still an open book: one blank spread to write on.
if(aoPages.length === 0) aoPages.push([]);
//A book always shows two pages, so pages come in pairs.
if(aoPages.length % 2 === 1) aoPages.push([]);
//Which page each entry starts and ends on, for bookmarks and for
//placing the writing surface.
for(const asPosition of asIndex.values()) {
asPosition.firstPage = Math.floor(asPosition.firstLine / iPerPage);
asPosition.lastPage = Math.floor(asPosition.lastLine / iPerPage);
asPosition.firstLineOnPage = asPosition.firstLine % iPerPage;
}
return {pages: aoPages, index: asIndex, lineCount: asLines.length, linesPerPage: iPerPage};
}
getEntryLines(oEntry) {
const asCached = this.asCache.get(oEntry.id);
if(asCached && asCached.text === oEntry.content) return asCached.lines;
const asLines = this.wrap(oEntry.content || '');
this.asCache.set(oEntry.id, {text: oEntry.content, lines: asLines});
return asLines;
}
forget(iEntryId) {
this.asCache.delete(iEntryId);
}
/* Measuring */
/**
* Break text into visual lines.
* @returns Array of {start, end} character offsets into the text
*/
wrap(sText) {
const asLines = [];
const asParagraphs = sText.split('\n');
let iBase = 0;
for(const sParagraph of asParagraphs) {
if(sParagraph === '') {
asLines.push({start: iBase, end: iBase});
}
else {
for(const asLine of this.wrapParagraph(sParagraph)) {
asLines.push({start: iBase + asLine.start, end: iBase + asLine.end});
}
}
//+1 for the newline that ended the paragraph
iBase += sParagraph.length + 1;
}
return asLines;
}
wrapParagraph(sParagraph) {
if(!this.ready) return [{start: 0, end: sParagraph.length}];
const oMeasurer = this.oMeasurer;
oMeasurer.textContent = sParagraph;
const iLineCount = Math.max(1, Math.round(oMeasurer.getBoundingClientRect().height / this.iLineHeight));
if(iLineCount === 1) return [{start: 0, end: sParagraph.length}];
const oNode = oMeasurer.firstChild;
const iOrigin = this.getCharTop(oNode, 0, sParagraph.length);
//The vertical position of a character never decreases as you move
//forward through wrapped left-to-right text, so each line's first
//character can be found by binary search instead of scanning.
const asStarts = [0];
let iFrom = 1;
for(let iLine = 1; iLine < iLineCount; iLine++) {
const iThreshold = (iLine - 0.5) * this.iLineHeight;
let iLow = iFrom;
let iHigh = sParagraph.length - 1;
let iFound = sParagraph.length;
while(iLow <= iHigh) {
const iMid = (iLow + iHigh) >> 1;
if((this.getCharTop(oNode, iMid, sParagraph.length) - iOrigin) >= iThreshold) {
iFound = iMid;
iHigh = iMid - 1;
}
else iLow = iMid + 1;
}
//Defensive: a mis-measured height would otherwise emit empty lines
//forever. Stop early rather than produce a broken page.
if(iFound >= sParagraph.length) break;
asStarts.push(iFound);
iFrom = iFound + 1;
}
return asStarts.map((iStart, iIndex) => ({
start: iStart,
end: (iIndex + 1 < asStarts.length) ? asStarts[iIndex + 1] : sParagraph.length
}));
}
getCharTop(oNode, iOffset, iLength) {
this.oRange.setStart(oNode, iOffset);
this.oRange.setEnd(oNode, Math.min(iOffset + 1, iLength));
return this.oRange.getBoundingClientRect().top;
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Every entry carries the timezone it was written in, and every stamp is
* formatted in that timezone rather than in the reader's current one - a
* journal written on a trip should still read as the local time of the moment.
* Timestamps cross the wire as UNIX seconds, so nothing here parses a string.
*/
const asFormatterCache = new Map();
function getFormatter(sLocale, sTimezone, asOptions) {
const sKey = sLocale + '|' + sTimezone + '|' + JSON.stringify(asOptions);
if(!asFormatterCache.has(sKey)) {
let oFormatter;
try {
oFormatter = new Intl.DateTimeFormat(sLocale, {...asOptions, timeZone: sTimezone});
}
catch{
//An unknown IANA name (renamed zone, hand-edited row) must not take
//the whole book down - fall back to the browser's own zone.
oFormatter = new Intl.DateTimeFormat(sLocale, asOptions);
}
asFormatterCache.set(sKey, oFormatter);
}
return asFormatterCache.get(sKey);
}
export function getBrowserTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
}
catch{
return '';
}
}
/** 'YYYY-MM-DD' for the given instant, as seen in the given timezone. */
export function getDayKey(iUnix, sTimezone) {
return getFormatter('en-CA', sTimezone, {year: 'numeric', month: '2-digit', day: '2-digit'}).format(iUnix * 1000);
}
/** 'YYYY-MM-DD' for a local Date, without the UTC shift toISOString() adds. */
export function getLocalDayKey(oDate) {
const sMonth = String(oDate.getMonth() + 1).padStart(2, '0');
const sDay = String(oDate.getDate()).padStart(2, '0');
return oDate.getFullYear() + '-' + sMonth + '-' + sDay;
}
export function formatDate(iUnix, sTimezone, sLocale) {
return getFormatter(sLocale, sTimezone, {day: 'numeric', month: 'short', year: 'numeric'}).format(iUnix * 1000);
}
export function formatShortDate(iUnix, sTimezone, sLocale) {
return getFormatter(sLocale, sTimezone, {day: 'numeric', month: 'short'}).format(iUnix * 1000);
}
export function formatTime(iUnix, sTimezone, sLocale) {
return getFormatter(sLocale, sTimezone, {hour: '2-digit', minute: '2-digit'}).format(iUnix * 1000);
}
export function formatWeekday(iUnix, sTimezone, sLocale) {
return getFormatter(sLocale, sTimezone, {weekday: 'long'}).format(iUnix * 1000);
}
export function formatFull(iUnix, sTimezone, sLocale) {
return getFormatter(sLocale, sTimezone, {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
}).format(iUnix * 1000);
}
export function formatMonthTitle(oDate, sLocale) {
return new Intl.DateTimeFormat(sLocale, {month: 'long', year: 'numeric'}).format(oDate);
}
/**
* Weekday initials starting on Monday, in the reader's locale.
*/
export function getWeekdayInitials(sLocale) {
const oFormatter = new Intl.DateTimeFormat(sLocale, {weekday: 'short'});
const asLabels = [];
//2024-01-01 was a Monday, which anchors the week without hard-coding names.
for(let iDay = 0; iDay < 7; iDay++) {
asLabels.push(oFormatter.format(new Date(Date.UTC(2024, 0, 1 + iDay))));
}
return asLabels;
}
/** Short "how long ago", used by the autosave indicator only. */
export function formatSince(iUnix, sLocale, sJustNow) {
const iSeconds = Math.round(Date.now() / 1000) - iUnix;
if(iSeconds < 45) return sJustNow;
const oFormatter = new Intl.RelativeTimeFormat(sLocale, {numeric: 'auto'});
if(iSeconds < 3600) return oFormatter.format(-Math.round(iSeconds / 60), 'minute');
if(iSeconds < 86400) return oFormatter.format(-Math.round(iSeconds / 3600), 'hour');
return oFormatter.format(-Math.round(iSeconds / 86400), 'day');
}
+190
View File
@@ -0,0 +1,190 @@
@use "@styles/color";
@use "@styles/var";
.app {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* Header: the shelf above the book. Deliberately thin - the book is the app */
.app__header {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var.$block-spacing;
padding: 0.6rem clamp(0.75rem, 2vw, 1.5rem);
position: relative;
z-index: 30;
}
.app__brand {
display: flex;
align-items: center;
gap: 0.7rem;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
margin: 0;
font-size: inherit;
font-weight: normal;
}
/* The logo is dark roast ink on a transparent bubble - drawn for paper, not for
* the desk. Rather than sit it on a light patch and break the surface, it is
* re-inked in cream: brightness(0) flattens it to a silhouette, invert lifts it
* to white, and the sepia/hue pass warms that back to the colour of the pages.
* Its full colour is kept for the sign-in leaf, which is paper. */
.app__logo {
height: 3.3rem;
width: auto;
display: block;
//brightness(0) flattens the artwork to a silhouette, invert lifts it to
//white, and the short sepia pass warms that to the colour of the pages.
//Kept deliberately simple - a longer chain only muddies it.
filter:
brightness(0)
invert(1)
sepia(0.22)
saturate(1.5)
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
opacity: 0.95;
}
.app__tagline {
font-size: 0.8rem;
color: rgba(255, 238, 210, 0.58);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app__tools {
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: var.$elem-spacing;
min-width: 0;
}
/* Main: the rail hangs off the right edge of the book, so they share a row */
.app__desk {
flex: 1 1 auto;
display: flex;
align-items: stretch;
justify-content: center;
gap: 0;
min-height: 0;
padding: 0 clamp(0.5rem, 2vw, 2rem) clamp(0.75rem, 2.5vh, 2rem);
}
.app__book {
flex: 1 1 auto;
display: flex;
min-width: 0;
max-width: var.$book-max-width;
}
/* Flex, so the rail inside inherits a definite height and scrolls internally.
* Left as a plain block it grows to the height of every tab in the book, which
* makes the whole document scrollable - and then focusing the writing surface
* scrolls the book itself out of view. */
.app__rail {
flex: 0 0 auto;
display: flex;
min-width: 0;
min-height: 0;
}
/* A popover anchored under a header button */
.app__popover {
position: absolute;
top: calc(100% - 0.2rem);
right: 0;
z-index: 40;
background-color: color.$paper;
border-radius: var.$block-radius;
box-shadow: var.$shadow-panel;
border: 1px solid color.$paper-deep;
}
.app__popover-anchor {
position: relative;
}
/* Loading & error strips */
.app__notice {
position: absolute;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: center;
gap: var.$text-spacing;
max-width: min(38rem, 90vw);
padding: 0.5rem 0.9rem;
border-radius: 999px;
font-size: 0.85rem;
color: #fff4e2;
background-color: rgba(29, 21, 15, 0.92);
box-shadow: var.$shadow-panel;
}
.app__notice--bad {
background-color: color.$ribbon-deep;
}
.app__notice-close {
color: rgba(255, 244, 226, 0.6);
display: inline-flex;
&:hover {
color: #fff4e2;
}
}
/* The account menu */
.account-menu {
min-width: 15rem;
padding: 0.35rem;
}
.account-menu__who {
padding: 0.5rem 0.65rem 0.55rem;
border-bottom: 1px solid color.$paper-edge;
margin-bottom: 0.35rem;
}
.account-menu__name {
font-family: var.$font-hand;
font-size: 1.35rem;
line-height: 1.1;
color: color.$ink;
}
.account-menu__email {
font-size: 0.78rem;
color: color.$ink-soft;
overflow: hidden;
text-overflow: ellipsis;
}
.account-menu__item {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.5rem 0.65rem;
border-radius: var.$block-radius;
text-align: left;
color: color.$ink;
transition: background-color var.$trans-quick;
&:hover,
&:focus-visible {
background-color: color.$paper-shade;
}
}
+608
View File
@@ -0,0 +1,608 @@
@use "sass:color" as sass-color;
@use "@styles/color";
@use "@styles/var";
/* Everything on a page is a multiple of one ruled line.
*
* `--book-line` is the height of that line and the single source of truth for
* the ruling, the text leading, the stamp positions and the caret. JS reads its
* resolved pixel value back with getComputedStyle to paginate, and writes
* `--book-lines` once it knows how many whole lines fit in the page body - so
* the paper never ends on half a rule. */
.book {
--book-line: #{var.$line-fallback};
--book-lines: 12;
flex: 1 1 auto;
display: flex;
//Stretch, not centre: the pages have to inherit a definite height, since
//how many ruled lines fit on one is measured from it.
align-items: stretch;
justify-content: center;
min-width: 0;
min-height: 0;
position: relative;
outline: 0;
}
/* The closed shell: the covers and the block of pages the spread sits on top of */
.book__shell {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
max-width: var.$book-max-width;
border-radius: var.$book-radius;
background-color: color.$paper-edge;
box-shadow: var.$shadow-page;
//The page block: a few stacked cut edges peeking out below and to the sides
&::before {
content: "";
position: absolute;
inset: -0.35rem -0.5rem -0.55rem;
z-index: -1;
border-radius: calc(var.$book-radius + 2px);
background-image: linear-gradient(
to bottom,
color.$paper-deep 0,
color.$paper-edge 22%,
color.$paper-deep 40%,
color.$paper-edge 62%,
color.$paper-deep 100%
);
box-shadow: 0 0.9rem 1.6rem color.$shadow-deep;
}
}
.book__spread {
flex: 1 1 auto;
display: flex;
align-items: stretch;
min-height: 0;
position: relative;
border-radius: var.$book-radius;
overflow: hidden;
background-color: color.$paper;
//Depth for the turning leaf. Long, because a book seen from a chair is
//nearly flat on - a short perspective makes the page fan out like a card
//trick rather than lift off the gutter.
perspective: 2600px;
perspective-origin: 50% 45%;
}
/* The gutter. Two paper surfaces meeting is the one place the book needs real
* shading: without it the spread reads as a single flat sheet. */
.book__spine {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var.$spine-width;
transform: translateX(-50%);
pointer-events: none;
z-index: 3;
background-image: linear-gradient(
to right,
rgba(120, 96, 62, 0) 0%,
rgba(120, 96, 62, 0.1) 32%,
rgba(96, 74, 46, 0.3) 47%,
rgba(72, 54, 32, 0.42) 50%,
rgba(96, 74, 46, 0.3) 53%,
rgba(120, 96, 62, 0.1) 68%,
rgba(120, 96, 62, 0) 100%
);
}
/* A single page */
.page {
flex: 1 1 50%;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
padding: var.$page-padding clamp(1rem, 2.2vw, 2.25rem);
//Paper, lit from the outside edge and darkening into the gutter
background-color: color.$paper;
background-image:
radial-gradient(120% 80% at 50% 0%, rgba(255, 255, 255, 0.5), transparent 55%),
repeating-linear-gradient(
117deg,
rgba(120, 96, 62, 0.012) 0 2px,
rgba(255, 255, 255, 0.02) 2px 5px
);
}
.page--left {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.45), transparent 30%),
linear-gradient(to right, transparent 70%, rgba(140, 112, 74, 0.09) 100%),
repeating-linear-gradient(
117deg,
rgba(120, 96, 62, 0.012) 0 2px,
rgba(255, 255, 255, 0.02) 2px 5px
);
box-shadow: inset -1px 0 0 rgba(140, 112, 74, 0.12);
}
.page--right {
background-image:
linear-gradient(to left, rgba(255, 255, 255, 0.45), transparent 30%),
linear-gradient(to left, transparent 70%, rgba(140, 112, 74, 0.09) 100%),
repeating-linear-gradient(
117deg,
rgba(120, 96, 62, 0.012) 0 2px,
rgba(255, 255, 255, 0.02) 2px 5px
);
}
/* The page body: margin column, then the ruled text column */
.page__body {
flex: 1 1 auto;
display: flex;
align-items: flex-start;
gap: 0;
min-height: 0;
position: relative;
}
.page__margin {
flex: 0 0 var.$page-margin-width;
position: relative;
height: calc(var(--book-lines) * var(--book-line));
padding-right: 0.7rem;
//The red margin rule every school notebook has
border-right: 1px solid color.$rule-margin;
}
/* Where an entry begins, the margin says when it was written */
.page__stamp {
position: absolute;
right: 0.7rem;
width: calc(100% - 0.7rem);
text-align: right;
font-family: var.$font-hand;
line-height: 1.05;
color: color.$ink-soft;
pointer-events: none;
}
.page__stamp-date {
display: block;
font-size: 1.05rem;
font-weight: 600;
white-space: nowrap;
}
.page__stamp-time {
display: block;
font-size: 0.95rem;
color: color.$ink-faint;
}
/* The text column. Height is an exact number of rules, so the ruling always
* ends flush with the bottom of the column. */
.page__column {
flex: 1 1 auto;
min-width: 0;
position: relative;
height: calc(var(--book-lines) * var(--book-line));
padding-left: 0.9rem;
//The ruling. Each rule sits at the bottom of its line's band.
background-image: repeating-linear-gradient(
to bottom,
transparent 0,
transparent calc(var(--book-line) - 1px),
color.$rule calc(var(--book-line) - 1px),
color.$rule var(--book-line)
);
}
/* Shared text metrics.
*
* The rendered lines, the hidden measurer and the input all have to wrap
* identically - the measurer is what decides where the breaks are, the lines
* are what the reader sees, and the input is what the arrow keys move through.
* Any drift between the three shows up as a caret in the wrong place, so the
* three of them take their typography from exactly one place: here. */
@mixin page-text-metrics {
font-family: var.$font-hand;
font-size: calc(var(--book-line) * 0.82);
line-height: var(--book-line);
letter-spacing: 0.005em;
word-spacing: 0.02em;
font-variant-ligatures: none;
tab-size: 4;
}
.page__lines {
@include page-text-metrics;
position: relative;
z-index: 1;
color: color.$ink;
}
/* One visual line, already broken by the paginator - so it must never re-wrap */
.page__line {
display: block;
height: var(--book-line);
white-space: pre;
overflow: hidden;
}
/* An entry that was closed reads as past writing: same hand, a touch lighter */
.page__line--closed {
color: sass-color.mix(color.$ink, color.$paper, 88%);
}
/* The hidden element the paginator measures in.
*
* Book.vue owns it and positions it inline over the real text column, at that
* column's measured width - so it wraps at exactly the width the finished lines
* will have, while staying one stable element across page turns. */
.page__measure {
@include page-text-metrics;
position: absolute;
top: 0;
left: 0;
visibility: hidden;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: break-word;
z-index: -1;
}
/* The writing surface.
*
* A textarea cannot flow from the left page onto the right one, so it is not
* what you look at: it is only where the keystrokes, the selection and the
* arrow keys live. Its text is transparent and the book draws the glyphs, the
* caret and the selection itself. It is sized and styled exactly like a page
* column, which means its own soft wrapping matches the paginator's - so Up and
* Down still move by the visual lines the reader can actually see. */
.page__input {
@include page-text-metrics;
//Placed inline by Book.vue over whichever column the caret is on, so the
//element itself survives every page turn and keeps its focus and selection.
position: absolute;
top: 0;
left: 0;
z-index: 2;
margin: 0;
padding: 0;
border: 0;
resize: none;
overflow: hidden;
background: transparent;
color: transparent;
caret-color: transparent;
//Clicks are handled by the page, which maps them to a character offset and
//moves the real caret there.
pointer-events: none;
&::selection {
background: transparent;
}
&:focus {
outline: 0;
}
}
/* The caret the book draws for itself */
.page__caret {
position: absolute;
width: 2px;
height: calc(var(--book-line) * 0.66);
margin-top: calc(var(--book-line) * 0.16);
background-color: color.$ribbon;
z-index: 4;
pointer-events: none;
animation: caret-blink 1.1s steps(1, end) infinite;
}
@keyframes caret-blink {
0%,
45% {
opacity: 1;
}
50%,
95% {
opacity: 0;
}
100% {
opacity: 1;
}
}
/* Selection, drawn per visual line since the native one is invisible */
.page__selection {
position: absolute;
height: calc(var(--book-line) * 0.86);
margin-top: calc(var(--book-line) * 0.07);
background-color: rgba(180, 126, 65, 0.3);
z-index: 0;
pointer-events: none;
border-radius: 2px;
}
/* Clicking anywhere on the writing page puts the caret there */
.page--writable {
cursor: text;
}
/* Page furniture */
.page__foot {
flex: 0 0 auto;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var.$elem-spacing;
padding-top: 0.6rem;
font-size: 0.72rem;
color: color.$ink-faint;
min-height: 1.6rem;
}
.page__number {
font-family: var.$font-hand;
font-size: 1rem;
color: color.$ink-faint;
}
.page--left .page__foot {
flex-direction: row-reverse;
}
.page__hint {
font-family: var.$font-hand;
font-size: 1.05rem;
color: color.$ink-faint;
}
/* The placeholder, shown on a page with nothing written on it yet */
.page__placeholder {
@include page-text-metrics;
position: absolute;
left: 0;
right: 0;
color: color.$ink-ghost;
white-space: pre;
pointer-events: none;
z-index: 1;
}
/* Turning.
*
* A real leaf, hinged on the gutter. Going forward it is the right-hand page
* that lifts and swings left - the direction your hand actually moves - and it
* carries the next left-hand page on its back, so what lands is what you then
* read. Going back is the mirror.
*
* The spread underneath shows a mix while this runs (see visiblePages): the
* side the leaf lifted from keeps its old page until the leaf covers it.
*/
.book__leaf {
position: absolute;
top: 0;
bottom: 0;
width: 50%;
z-index: 7;
pointer-events: none;
transform-style: preserve-3d;
will-change: transform;
}
.book__leaf--forward {
left: 50%;
transform-origin: left center;
animation: leaf-forward var.$trans-slow cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
}
.book__leaf--back {
left: 0;
transform-origin: right center;
animation: leaf-back var.$trans-slow cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
}
//Right page sweeping left across the gutter
@keyframes leaf-forward {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(-180deg);
}
}
//Left page sweeping right across the gutter
@keyframes leaf-back {
from {
transform: rotateY(0deg);
}
to {
transform: rotateY(180deg);
}
}
.book__leaf-face {
position: absolute;
inset: 0;
overflow: hidden;
backface-visibility: hidden;
background-color: color.$paper;
box-shadow: 0 0 2rem rgba(20, 14, 9, 0.35);
//The page fills the leaf rather than half a spread
.page {
width: 100%;
height: 100%;
}
}
//The back of a leaf is only seen once it has swung past upright
.book__leaf-face--back {
transform: rotateY(180deg);
}
/* Paper catches the light as it lifts and loses it as it lands. Two passes of
* the same shade, offset by half the turn, is what sells the leaf as solid. */
.book__leaf-shade {
position: absolute;
inset: 0;
pointer-events: none;
}
.book__leaf-face--front .book__leaf-shade {
background-image: linear-gradient(to left, rgba(20, 14, 9, 0.42), rgba(20, 14, 9, 0.04) 45%, transparent 75%);
animation: leaf-shade-out var.$trans-slow ease-in forwards;
}
.book__leaf-face--back .book__leaf-shade {
background-image: linear-gradient(to right, rgba(20, 14, 9, 0.46), rgba(20, 14, 9, 0.06) 45%, transparent 75%);
animation: leaf-shade-in var.$trans-slow ease-out forwards;
}
@keyframes leaf-shade-out {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes leaf-shade-in {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/* The gutter darkens under a leaf that is standing up over it */
.book__turning .book__spine {
animation: gutter-deepen var.$trans-slow ease-in-out;
}
@keyframes gutter-deepen {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
/* One page per view has no gutter to hinge on, so the page is dealt off the
* pile in the direction of travel instead of pretending to be bound. */
.book__spread--single.book__turning--forward .page {
animation: page-in-from-right var.$trans-mid ease-out;
}
.book__spread--single.book__turning--back .page {
animation: page-in-from-left var.$trans-mid ease-out;
}
@keyframes page-in-from-right {
from {
transform: translateX(14%);
opacity: 0.2;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes page-in-from-left {
from {
transform: translateX(-14%);
opacity: 0.2;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Turn controls, tucked into the outer edge of each page */
.book__turner {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
width: 2.4rem;
height: 3.4rem;
color: color.$ink-faint;
border-radius: var.$block-radius;
transition: color var.$trans-quick, background-color var.$trans-quick;
&:hover:not(:disabled) {
color: color.$ribbon;
background-color: rgba(140, 112, 74, 0.08);
}
&:disabled {
opacity: 0.25;
cursor: default;
}
}
.book__turner--back {
left: 0.1rem;
}
.book__turner--forward {
right: 0.1rem;
}
/* The ribbon, hanging out of the gutter. Marks the page being written on. */
.book__ribbon {
position: absolute;
top: 0;
left: calc(50% - 0.55rem);
width: 1.1rem;
z-index: 4;
pointer-events: none;
background-image: linear-gradient(to right, color.$ribbon-deep, color.$ribbon 45%, color.$ribbon-deep);
box-shadow: 0 0 0.4rem rgba(20, 14, 9, 0.3);
transition: height var.$trans-mid ease-out, opacity var.$trans-quick;
&::after {
content: "";
position: absolute;
bottom: -0.7rem;
left: 0;
width: 100%;
height: 0.75rem;
background-color: color.$ribbon;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 55%, 0 100%);
}
}
+149
View File
@@ -0,0 +1,149 @@
@use "@styles/color";
@use "@styles/var";
/* The calendar. A month at a time, with a mark under every day that has
* writing on it - so it doubles as a picture of how the journal is kept. */
.calendar {
width: 19rem;
padding: 0.75rem;
font-size: 0.85rem;
}
.calendar__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var.$elem-spacing;
margin-bottom: 0.5rem;
}
.calendar__month {
font-family: var.$font-hand;
font-size: 1.35rem;
line-height: 1;
color: color.$ink;
text-transform: capitalize;
}
.calendar__step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: var.$block-radius;
color: color.$ink-soft;
transition: background-color var.$trans-quick, color var.$trans-quick;
&:hover:not(:disabled) {
color: color.$ink;
background-color: color.$paper-shade;
}
&:disabled {
opacity: 0.3;
cursor: default;
}
}
.calendar__grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.1rem;
}
.calendar__weekday {
text-align: center;
font-size: 0.66rem;
letter-spacing: 0.03em;
text-transform: uppercase;
color: color.$ink-faint;
padding-bottom: 0.25rem;
}
.calendar__day {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: var.$block-radius;
color: color.$ink;
transition: background-color var.$trans-quick, color var.$trans-quick;
&:hover:not(:disabled) {
background-color: color.$paper-shade;
}
&:disabled {
color: color.$ink-faint;
cursor: default;
}
}
.calendar__day--outside {
color: color.$ink-faint;
opacity: 0.55;
}
/* A day with writing on it. The dot is the affordance; the weight change is
* what you actually notice when scanning a month. */
.calendar__day--written {
font-weight: 600;
&::after {
content: "";
position: absolute;
bottom: 0.18rem;
left: 50%;
transform: translateX(-50%);
width: 0.3rem;
height: 0.3rem;
border-radius: 50%;
background-color: color.$ribbon;
}
}
.calendar__day--today {
box-shadow: inset 0 0 0 1px color.$caramel;
}
.calendar__day--current {
color: #fff6e6;
background-color: color.$ribbon;
&:hover {
background-color: color.$ribbon;
}
&::after {
background-color: #fff6e6;
}
}
.calendar__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: var.$elem-spacing;
margin-top: 0.55rem;
padding-top: 0.5rem;
border-top: 1px solid color.$paper-edge;
}
.calendar__link {
font-size: 0.78rem;
color: color.$ink-soft;
padding: 0.25rem 0.4rem;
border-radius: var.$block-radius;
&:hover {
color: color.$ink;
background-color: color.$paper-shade;
}
}
.calendar__count {
font-size: 0.72rem;
color: color.$ink-faint;
}
+52
View File
@@ -0,0 +1,52 @@
/* The palette is taken from the logo, not chosen alongside it.
*
* src/images/logo.png is dark-roast lettering in a thought bubble with a cup of
* coffee; sampling it gives one dominant near-black brown (#2c2114) and a run of
* caramels from #795731 up to #b47e41. Those are the colours below. The book is
* the same idea in furniture: a coffee-dark desk, cream paper, and caramel for
* anything that has to catch the eye.
*
* There is no pure grey and no pure black anywhere - paper and lamplight do not
* have any, and neither does the logo. */
//The desk the book lies on - straight from the logo's darkest inks
$desk-deep: #1a140d;
$desk: #2c2114;
$desk-edge: #44321e;
//Paper. -shade is the tint towards the spine, -edge the cut edge of the stack
$paper: #f8f2e4;
$paper-shade: #f0e7d3;
$paper-edge: #e0d3b8;
$paper-deep: #cdbb99;
//Ink, in the three weights the book uses: what you wrote, what the book says
//about it, and what is barely there
$ink: #2e2822;
$ink-soft: #6d6358;
$ink-faint: #a29684;
$ink-ghost: rgba(46, 40, 34, 0.38);
//Ruling. The horizontal rules are cold on purpose - they are the one thing on
//the page that is not ink, and that contrast is what makes paper read as paper
$rule: rgba(90, 120, 150, 0.22);
$rule-margin: rgba(168, 118, 62, 0.5);
//Accents: the coffee and the steam swirl
$caramel: #b47e41;
$caramel-deep: #795731;
$caramel-light: #d0a06a;
//The ribbon marking the page being written on
$ribbon: $caramel-deep;
$ribbon-deep: #5c4428;
//Feedback. Brick rather than red, so a warning still belongs to the palette
$ok: #5c6b3a;
$warn: #a8763e;
$bad: #9c4a34;
//Overlays
$veil: rgba(20, 14, 9, 0.74);
$shadow-soft: rgba(20, 14, 9, 0.18);
$shadow-deep: rgba(20, 14, 9, 0.45);
+151
View File
@@ -0,0 +1,151 @@
@use "@styles/color";
@use "@styles/var";
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
}
body {
font-family: var.$font-ui;
font-size: 16px;
color: color.$ink;
background-color: color.$desk-deep;
//The desk: a warm pool of lamplight falling on wood, with the grain done as
//a pair of very low-contrast repeating gradients rather than a bitmap.
background-image:
radial-gradient(ellipse 120% 90% at 50% -10%, rgba(255, 226, 178, 0.16), transparent 60%),
repeating-linear-gradient(
92deg,
rgba(0, 0, 0, 0.05) 0 3px,
rgba(255, 255, 255, 0.014) 3px 7px,
rgba(0, 0, 0, 0.035) 7px 11px
),
linear-gradient(160deg, color.$desk-edge 0%, color.$desk 45%, color.$desk-deep 100%);
background-attachment: fixed;
overflow: hidden;
overscroll-behavior: none;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
#container {
height: 100%;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
border: 0;
background: none;
padding: 0;
}
input,
select,
textarea {
font: inherit;
color: inherit;
//Checkboxes and the like belong to the palette, not to the browser
accent-color: color.$caramel-deep;
}
/* Buttons that sit on the desk rather than on the paper: brass-ish, restrained */
.desk-button {
display: inline-flex;
align-items: center;
gap: var.$text-spacing;
padding: 0.45rem 0.8rem;
border-radius: var.$block-radius;
color: rgba(255, 244, 224, 0.82);
background-color: rgba(255, 240, 214, 0.07);
border: 1px solid rgba(255, 240, 214, 0.14);
transition: background-color var.$trans-quick, color var.$trans-quick, border-color var.$trans-quick;
white-space: nowrap;
&:hover,
&:focus-visible {
color: #fff8ea;
background-color: rgba(255, 240, 214, 0.14);
border-color: rgba(255, 240, 214, 0.28);
}
&[aria-expanded="true"],
&.is-active {
color: #fff8ea;
background-color: rgba(255, 240, 214, 0.18);
border-color: rgba(255, 240, 214, 0.34);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.desk-button--icon {
padding: 0.45rem;
}
/* Text inputs on paper */
.paper-field {
display: block;
width: 100%;
padding: 0.55rem 0.7rem;
color: color.$ink;
background-color: rgba(255, 255, 255, 0.55);
border: 1px solid color.$paper-deep;
border-radius: var.$block-radius;
transition: border-color var.$trans-quick, box-shadow var.$trans-quick;
&:focus {
outline: 0;
border-color: color.$ribbon;
box-shadow: 0 0 0 3px rgba(163, 55, 47, 0.14);
}
}
.paper-label {
display: block;
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: color.$ink-soft;
margin-bottom: 0.3rem;
}
/* Screen-reader-only, for the labels the book conveys visually */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
:focus-visible {
outline: 2px solid color.$caramel;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+214
View File
@@ -0,0 +1,214 @@
@use "@styles/color";
@use "@styles/var";
/* A phone cannot hold a spread, so it holds a page.
*
* Book.vue decides that (it paginates one page per view instead of two), and
* the styles here just take the second half of the furniture away: no gutter,
* no ribbon down the middle, and the rail becomes a drawer instead of tabs
* sticking out of a book that no longer has room beside it. */
.book__spread--single {
.book__spine,
.book__ribbon {
display: none;
}
.page {
flex: 1 1 100%;
}
}
/* Shown only where the rail has to be summoned */
.app__rail-toggle {
display: none;
}
/* Narrow desks give the rail less room, but never so little that the date and
* time stop being readable - that is the whole content of a bookmark. */
@media (max-width: var.$narrow) {
.rail {
width: 9.5rem;
}
.rail__preview {
display: none;
}
}
@media (max-width: var.$mobile) {
body {
font-size: 15px;
}
.app__header {
padding: 0.5rem 0.6rem;
gap: 0.5rem;
}
.app__logo {
height: 2.2rem;
}
.app__tagline {
display: none;
}
/* The dot alone still says saved / saving / unsaved, and the header has no
* room for the sentence that goes with it. */
.saver span:not(.saver__dot) {
display: none;
}
.saver {
padding: 0.35rem 0.2rem;
}
.app__desk {
padding: 0 0.4rem 0.5rem;
}
.app__rail-toggle {
display: inline-flex;
}
.page {
padding: 1.1rem 0.85rem;
}
.page__margin {
flex-basis: 3.1rem;
padding-right: 0.4rem;
}
.page__stamp {
right: 0.4rem;
width: calc(100% - 0.4rem);
}
.page__stamp-date {
font-size: 0.9rem;
}
.page__stamp-time {
font-size: 0.82rem;
}
.page__column,
.page__measure,
.page__input,
.page__placeholder {
padding-left: 0;
left: 0;
}
.page__column {
padding-left: 0.55rem;
}
.page__measure,
.page__input,
.page__placeholder {
left: 0.55rem;
}
.book__turner {
width: 2rem;
height: 2.8rem;
}
/* The rail, as a drawer off the right edge */
.app__rail {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 90;
width: min(17rem, 82vw);
padding: 0.75rem 0.5rem 0.75rem 0.75rem;
background-color: rgba(29, 21, 15, 0.96);
box-shadow: var.$shadow-panel;
transform: translateX(100%);
transition: transform var.$trans-mid ease-out;
}
.app__rail--open {
transform: translateX(0);
}
.rail {
width: 100%;
margin-left: 0;
height: 100%;
}
.rail__tab {
width: 100%;
margin-left: 0;
border-radius: var.$block-radius;
&:hover,
&:focus-visible,
&.rail__tab--current {
margin-left: 0;
}
}
.rail__preview {
max-width: 100%;
}
.rail__empty {
writing-mode: horizontal-tb;
}
.calendar {
width: min(19rem, calc(100vw - 1.5rem));
}
.app__popover {
right: -0.2rem;
}
}
/* Short viewports: the header is the only thing that can give ground */
@media (max-height: 560px) {
.app__header {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.app__logo {
height: 2rem;
}
.page {
padding-top: 1rem;
padding-bottom: 1rem;
}
}
/* Printing a journal should give paper, not an app */
@media print {
body {
background: #fff;
}
.app__header,
.app__rail,
.book__turner,
.book__ribbon,
.page__input,
.page__caret {
display: none !important;
}
.book__shell,
.book__spread {
box-shadow: none;
}
.book__shell::before {
display: none;
}
}
+187
View File
@@ -0,0 +1,187 @@
@use "@styles/color";
@use "@styles/var";
/* Overlays: the sign-in card, and the settings sheet.
* Both are the same object - a single leaf of paper on the desk. */
.veil {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: var.$block-spacing;
background-color: color.$veil;
backdrop-filter: blur(2px);
}
.leaf {
width: min(26rem, 100%);
max-height: 100%;
overflow-y: auto;
padding: 1.5rem;
background-color: color.$paper;
border-radius: var.$block-radius;
box-shadow: var.$shadow-panel;
//A cut edge along the left, so it reads as torn from the book
background-image: linear-gradient(to right, color.$paper-edge 0 3px, transparent 3px);
}
.leaf--wide {
width: min(34rem, 100%);
}
.leaf__head {
margin-bottom: 1.1rem;
}
.leaf__logo {
display: block;
width: min(15rem, 70%);
height: auto;
margin: 0 0 0.6rem -0.4rem;
}
.leaf__title {
margin: 0;
font-family: var.$font-hand;
font-size: 2rem;
font-weight: 600;
line-height: 1.05;
color: color.$ink;
}
.leaf__sub {
margin-top: 0.15rem;
font-size: 0.85rem;
color: color.$ink-soft;
}
.leaf__row {
margin-bottom: 0.85rem;
}
.leaf__actions {
display: flex;
align-items: center;
gap: var.$elem-spacing;
margin-top: 1.2rem;
}
.leaf__check {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.85rem;
color: color.$ink-soft;
cursor: pointer;
}
.leaf__error {
margin-bottom: 0.85rem;
padding: 0.5rem 0.7rem;
border-radius: var.$block-radius;
font-size: 0.85rem;
color: color.$ribbon-deep;
background-color: rgba(163, 55, 47, 0.09);
border-left: 2px solid color.$ribbon;
}
.leaf__note {
margin-top: 0.85rem;
font-size: 0.82rem;
color: color.$ink-soft;
}
/* The one solid button in the app */
.ink-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var.$text-spacing;
padding: 0.55rem 1.1rem;
border-radius: var.$block-radius;
color: #fff6e6;
background-color: color.$ribbon;
box-shadow: var.$shadow-elem;
transition: background-color var.$trans-quick;
&:hover:not(:disabled),
&:focus-visible {
background-color: color.$ribbon-deep;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.text-button {
padding: 0.55rem 0.6rem;
border-radius: var.$block-radius;
color: color.$ink-soft;
transition: color var.$trans-quick, background-color var.$trans-quick;
&:hover,
&:focus-visible {
color: color.$ink;
background-color: color.$paper-shade;
}
}
.text-button--bad {
color: color.$ribbon;
&:hover,
&:focus-visible {
color: color.$ribbon-deep;
background-color: rgba(163, 55, 47, 0.08);
}
}
/* The autosave indicator. Lives in the header and must never shout: it is
* ambient reassurance, not a notification. */
.saver {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
color: rgba(255, 238, 210, 0.5);
padding: 0.35rem 0.5rem;
white-space: nowrap;
transition: color var.$trans-quick;
}
.saver--saving,
.saver--pending {
color: rgba(255, 238, 210, 0.75);
}
.saver--failed {
color: #f0b6ae;
}
.saver__dot {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background-color: currentColor;
flex: 0 0 auto;
}
.saver--saving .saver__dot {
animation: saver-pulse 1s ease-in-out infinite;
}
@keyframes saver-pulse {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 1;
}
}
+162
View File
@@ -0,0 +1,162 @@
@use "@styles/color";
@use "@styles/var";
/* The bookmark rail.
*
* One tab per entry, in writing order, sliding out from behind the right edge
* of the book. The negative margin is what sells it: the tabs start underneath
* the page block and only their labelled ends stick out, the way a stack of
* sticky tabs would. */
.rail {
display: flex;
flex-direction: column;
min-height: 0;
//The tabs scroll within the rail; the rail never grows the page
height: 100%;
width: var.$tab-width;
margin-left: 0;
padding: 0.35rem 0;
position: relative;
z-index: 4;
}
/* The tabs say what they are; a heading would only overflow the narrow rail
* and print itself across the edge of the book. Kept for screen readers. */
.rail__title {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.rail__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.15rem 0;
&::-webkit-scrollbar {
width: 0;
}
}
/* A tab.
*
* Only its bound edge is tucked under the page block - the rest stands clear,
* because a bookmark whose date you cannot read until you hover it is not a
* bookmark. Hovering slides it further out to bring the preview into the light.
*/
.rail__tab {
flex: 0 0 auto;
position: relative;
display: block;
width: 100%;
min-height: var.$tab-height;
margin-left: calc(-1 * var.$tab-tuck);
padding: 0.4rem 0.65rem 0.45rem calc(var.$tab-tuck + 0.55rem);
text-align: left;
color: color.$ink-soft;
background-color: color.$paper-shade;
//The shadowed strip that reads as the part still inside the book
background-image: linear-gradient(to right, rgba(90, 68, 40, 0.22), rgba(90, 68, 40, 0.04) var.$tab-tuck, transparent calc(var.$tab-tuck + 0.5rem));
border-radius: 0 var.$block-radius var.$block-radius 0;
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.3);
transition: transform var.$trans-mid ease-out, background-color var.$trans-quick, color var.$trans-quick;
&:hover,
&:focus-visible {
transform: translateX(0.45rem);
background-color: color.$paper;
color: color.$ink;
}
}
/* The entry the book is currently showing */
.rail__tab--current {
transform: translateX(0.3rem);
background-color: color.$paper;
color: color.$ink;
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.34), inset -0.2rem 0 0 color.$caramel;
&:hover,
&:focus-visible {
transform: translateX(0.65rem);
}
}
/* The entry still being written */
.rail__tab--open {
box-shadow: 0.12rem 0.15rem 0.5rem rgba(20, 14, 9, 0.34), inset -0.2rem 0 0 color.$ribbon;
}
.rail__tab--current.rail__tab--open {
box-shadow: 0.12rem 0.15rem 0.6rem rgba(20, 14, 9, 0.4), inset -0.2rem 0 0 color.$ribbon;
}
.rail__date {
display: flex;
align-items: baseline;
gap: 0.4rem;
font-family: var.$font-hand;
line-height: 1.15;
white-space: nowrap;
}
.rail__day {
font-size: 1.2rem;
font-weight: 600;
color: inherit;
}
.rail__time {
font-size: 1rem;
color: color.$ink-faint;
}
/* Only visible once the tab has slid out */
.rail__preview {
display: block;
margin-top: 0.05rem;
font-size: 0.72rem;
line-height: 1.25;
color: color.$ink-faint;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.rail__empty {
font-family: var.$font-hand;
font-size: 1.05rem;
color: rgba(255, 238, 210, 0.35);
padding: 0.5rem 0.75rem;
writing-mode: vertical-rl;
white-space: nowrap;
}
/* "Load older entries" sits at the top of the rail, since the rail is
* chronological and the oldest entry is the first tab. */
.rail__more {
flex: 0 0 auto;
align-self: flex-start;
margin-left: 0;
padding: 0.3rem 0.4rem;
font-size: 0.7rem;
color: rgba(255, 238, 210, 0.55);
border-radius: var.$block-radius;
&:hover {
color: #fff8ea;
background-color: rgba(255, 240, 214, 0.1);
}
}
+47
View File
@@ -0,0 +1,47 @@
@use "@styles/color";
/* The ruled line is the unit this whole layout is built from: the rules on the
* paper, the height of a page, the vertical position of every margin stamp and
* of the caret are all multiples of it. It is declared as a CSS custom property
* rather than a Sass variable because JS has to read the resolved pixel value
* back out to paginate, and has to be able to do that after a resize. */
$line-fallback: 2.1rem;
//Sizes
$elem-spacing: 0.5rem;
$text-spacing: 0.3em;
$block-spacing: 1rem;
$block-radius: 3px;
//The book
$page-margin-width: 4.25rem; //the left column that carries date stamps
$page-padding: 2.25rem;
$spine-width: 2.5rem;
$book-max-width: 72rem; //leaves the rail room to show its dates
$book-radius: 4px;
//The bookmark rail
$tab-width: 12.5rem;
$tab-height: 3.1rem;
//How far a tab is tucked under the edge of the book. Small on purpose: the date
//and time are the whole point of a bookmark, so the tab is read in full and
//only its bound edge disappears under the page block.
$tab-tuck: 1.4rem;
//Typography
$font-hand: "Caveat Variable", "Caveat", "Segoe Script", cursive;
$font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
//Transitions
$trans-quick: 160ms;
$trans-mid: 280ms;
$trans-slow: 600ms;
//Elevation
$shadow-page: 0 1.5rem 3rem color.$shadow-deep;
$shadow-panel: 0 0.75rem 2rem color.$shadow-deep;
$shadow-elem: 0 1px 2px color.$shadow-soft;
//Breakpoints
$mobile: 860px;
$narrow: 1180px;
+18
View File
@@ -0,0 +1,18 @@
/* Site Global CSS */
@use '@styles/common';
/* The book */
@use '@styles/app';
@use '@styles/book';
@use '@styles/rail';
/* Furniture */
@use '@styles/calendar';
@use '@styles/panel';
@use '@styles/mobile';
/* The hand the journal is written in. Only the variable face is pulled in -
* the paginator measures whatever is actually loaded, so the font has to be
* settled (document.fonts.ready) before the first layout, not merely linked. */
@import '@fontsource-variable/caveat/index.css';
Binary file not shown.
Binary file not shown.
-195
View File
@@ -1,195 +0,0 @@
/*!
* Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome
* License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
*/
/* FONT PATH
* -------------------------- */
@font-face {
font-family: 'FontAwesome';
src: url('fontawesome-webfont.eot?v=4.3.0');
src: url('fontawesome-webfont.eot?#iefix&v=4.3.0') format('embedded-opentype'), url('fontawesome-webfont.woff2?v=4.3.0') format('woff2'), url('fontawesome-webfont.woff?v=4.3.0') format('woff'), url('fontawesome-webfont.ttf?v=4.3.0') format('truetype'), url('fontawesome-webfont.svg?v=4.3.0#fontawesomeregular') format('svg');
font-weight: normal;
font-style: normal;
}
.fa {
display: inline-block;
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transform: translate(0, 0);
}
/* makes the font 33% larger relative to the icon container */
.fa-lg {
font-size: 1.33333333em;
line-height: 0.75em;
vertical-align: -15%;
}
.fa-2x {
font-size: 2em;
}
.fa-3x {
font-size: 3em;
}
.fa-4x {
font-size: 4em;
}
.fa-5x {
font-size: 5em;
}
.fa-fw {
width: 1.28571429em;
text-align: center;
}
.fa-ul {
padding-left: 0;
margin-left: 2.14285714em;
list-style-type: none;
}
.fa-ul > li {
position: relative;
}
.fa-li {
position: absolute;
left: -2.14285714em;
width: 2.14285714em;
top: 0.14285714em;
text-align: center;
}
.fa-li.fa-lg {
left: -1.85714286em;
}
.fa-border {
padding: .2em .25em .15em;
border: solid 0.08em #eeeeee;
border-radius: .1em;
}
.pull-right {
float: right;
}
.pull-left {
float: left;
}
.fa.pull-left {
margin-right: .3em;
}
.fa.pull-right {
margin-left: .3em;
}
.fa-spin {
-webkit-animation: fa-spin 2s infinite linear;
animation: fa-spin 2s infinite linear;
}
.fa-pulse {
-webkit-animation: fa-spin 1s infinite steps(8);
animation: fa-spin 1s infinite steps(8);
}
@-webkit-keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(359deg);
transform: rotate(359deg);
}
}
@keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(359deg);
transform: rotate(359deg);
}
}
.fa-rotate-90 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1);
-webkit-transform: rotate(90deg);
-ms-transform: rotate(90deg);
transform: rotate(90deg);
}
.fa-rotate-180 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2);
-webkit-transform: rotate(180deg);
-ms-transform: rotate(180deg);
transform: rotate(180deg);
}
.fa-rotate-270 {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3);
-webkit-transform: rotate(270deg);
-ms-transform: rotate(270deg);
transform: rotate(270deg);
}
.fa-flip-horizontal {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1);
-webkit-transform: scale(-1, 1);
-ms-transform: scale(-1, 1);
transform: scale(-1, 1);
}
.fa-flip-vertical {
filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1);
-webkit-transform: scale(1, -1);
-ms-transform: scale(1, -1);
transform: scale(1, -1);
}
:root .fa-rotate-90,
:root .fa-rotate-180,
:root .fa-rotate-270,
:root .fa-flip-horizontal,
:root .fa-flip-vertical {
filter: none;
}
.fa-stack {
position: relative;
display: inline-block;
width: 2em;
height: 2em;
line-height: 2em;
vertical-align: middle;
}
.fa-stack-1x,
.fa-stack-2x {
position: absolute;
left: 0;
width: 100%;
text-align: center;
}
.fa-stack-1x {
line-height: inherit;
}
.fa-stack-2x {
font-size: 2em;
}
.fa-inverse {
color: #ffffff;
}
.fa-gear:before {
content: "\f013";
}
.fa-bold:before {
content: "\f032";
}
.fa-italic:before {
content: "\f033";
}
.fa-underline:before {
content: "\f0cd";
}
.fa-ol:before {
content: "\f0cb";
}
.fa-ul:before {
content: "\f03a";
}
.fa-strike:before {
content: "\f0cc";
}
.fa-next:before {
content: "\f105";
}
.fa-prev:before {
content: "\f104";
}
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More