Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78c1d8e844 | ||
|
|
28e1616c94 | ||
|
|
da47b39315 | ||
|
|
549a7e0fa0 |
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
## Jul - deny access to the top-level git repository
|
||||
RewriteEngine On
|
||||
RewriteRule \.git - [F,L]
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "franzz/daydream",
|
||||
"description": "Daydream",
|
||||
"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\\Daydream\\": "lib/",
|
||||
"Franzz\\Objects\\": "../objects/inc/"
|
||||
},
|
||||
"files": [
|
||||
"config/settings.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "php-cs-fixer fix --dry-run --diff",
|
||||
"lint-fix": "php-cs-fixer fix"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "franzz/daydream",
|
||||
"description": "Daydream",
|
||||
"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\\Daydream\\": "lib/"
|
||||
},
|
||||
"files": [
|
||||
"config/settings.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "php-cs-fixer fix --dry-run --diff",
|
||||
"lint-fix": "php-cs-fixer fix"
|
||||
}
|
||||
}
|
||||
@@ -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 à 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__.'()]');
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,46 @@
|
||||
# Serve https://localhost/daydream/ 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 /daydream /var/www/html/daydream/public
|
||||
|
||||
<Directory /var/www/html/daydream/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>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
class Settings {
|
||||
public const DB_SERVER = 'localhost';
|
||||
public const DB_LOGIN = '';
|
||||
public const DB_PASS = '';
|
||||
public const DB_NAME = 'daydream';
|
||||
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__;
|
||||
}
|
||||
@@ -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/**']
|
||||
}
|
||||
];
|
||||
|
Before Width: | Height: | Size: 216 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 247 B |
|
Before Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 636 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 114 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -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();
|
||||
|
||||
*/
|
||||
?>
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\Daydream;
|
||||
|
||||
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 Daydream $oDaydream;
|
||||
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->oDaydream = new Daydream($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 = Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
else $sResult = ($sAction == '') ? $this->oDaydream->getAppMainPage($this->getCsrfToken()) : $this->dispatch($sAction);
|
||||
|
||||
//Clean errors
|
||||
$sDebug = ob_get_clean();
|
||||
if($sDebug != '') $this->oDaydream->addUncaughtError($sDebug);
|
||||
$this->closeSession();
|
||||
|
||||
return $sResult;
|
||||
}
|
||||
|
||||
private function dispatch(string $sAction): string {
|
||||
$oJournal = $this->oDaydream->getJournal();
|
||||
|
||||
return match($sAction) {
|
||||
/* Account */
|
||||
'signup' => $this->oDaydream->signup($this->asReq['name'], $this->asReq['email'], $this->asReq['password'], $this->asReq['t']),
|
||||
'login' => $this->oDaydream->login($this->asReq['email'], $this->asReq['password'], $this->asReq['t'], $this->asReq['remember']),
|
||||
'logout' => $this->oDaydream->logout(),
|
||||
'account' => $this->oDaydream->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 => Daydream::getJsonResult(false, Daydream::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]]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\Daydream;
|
||||
|
||||
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 Daydream extends Main {
|
||||
public const PROJECT_NAME = 'Daydream';
|
||||
public const DEFAULT_LANG = 'en';
|
||||
|
||||
/**
|
||||
* The hands the book can be written in.
|
||||
*
|
||||
* One list, here, because two things need it and they must not drift: the
|
||||
* settings picker offers it, and updateSettings() validates against it.
|
||||
*
|
||||
* `scale` is the type size as a fraction of one ruled line. It is not
|
||||
* cosmetic - every one of these hands has a different x-height for the same
|
||||
* em, so a single size would leave one sitting on the rule and the next
|
||||
* floating above it. The values are measured, not chosen.
|
||||
*
|
||||
* Adding one here also needs its webfont imported in src/scripts/fonts.js.
|
||||
*/
|
||||
public const HANDS = [
|
||||
['id' => 'caveat', 'name' => 'Caveat', 'family' => '"Caveat Variable", "Caveat"', 'scale' => 0.82],
|
||||
['id' => 'kalam', 'name' => 'Kalam', 'family' => '"Kalam"', 'scale' => 0.81],
|
||||
['id' => 'patrick', 'name' => 'Patrick Hand', 'family' => '"Patrick Hand"', 'scale' => 0.8],
|
||||
['id' => 'shadows', 'name' => 'Shadows Into Light', 'family' => '"Shadows Into Light"', 'scale' => 0.7],
|
||||
['id' => 'architect', 'name' => "Architect's Daughter",'family' => '"Architects Daughter"', 'scale' => 0.68],
|
||||
['id' => 'indie', 'name' => 'Indie Flower', 'family' => '"Indie Flower"', 'scale' => 0.67]
|
||||
];
|
||||
|
||||
public const DEFAULT_HAND = 'caveat';
|
||||
|
||||
/** @return string[] ids of every hand on offer */
|
||||
public static function getHandIds(): array {
|
||||
return array_column(self::HANDS, 'id');
|
||||
}
|
||||
|
||||
//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', 'hand', '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',
|
||||
'hand' => "VARCHAR(20) NOT NULL DEFAULT '".self::DEFAULT_HAND."'",
|
||||
'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,
|
||||
'hands' => self::HANDS,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\Daydream;
|
||||
|
||||
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 Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
|
||||
$this->sealStaleEntries();
|
||||
|
||||
$asEntries = $this->getEntryWindow();
|
||||
|
||||
return Daydream::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 Daydream::getJsonResult(false, Daydream::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 Daydream::getJsonResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
return Daydream::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 Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
|
||||
$oDate = \DateTime::createFromFormat('Y-m-d', $sDate);
|
||||
if(!$oDate) return Daydream::getJsonResult(false, Daydream::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 Daydream::getJsonResult(false, 'book.no_entry_yet');
|
||||
|
||||
return Daydream::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 Daydream::getJsonResult(false, Daydream::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' => Daydream::ZERO_TIMESTAMP,
|
||||
'timezone' => date_default_timezone_get()
|
||||
]);
|
||||
|
||||
if($iEntryId <= 0) return Daydream::getJsonResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return Daydream::getJsonResult(true, '', ['entry' => $this->getEntryById($iEntryId)]);
|
||||
}
|
||||
|
||||
public function saveEntry(int $iEntryId, string $sContent): string {
|
||||
if(!$this->oUser->isLoggedIn()) return Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return Daydream::getJsonResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
$sContent = self::normaliseContent($sContent);
|
||||
|
||||
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, ['content' => $sContent]) === false) {
|
||||
return Daydream::getJsonResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return Daydream::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 Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return Daydream::getJsonResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
$asResult = $this->sealEntry($iEntryId, $bHasContent ? $sContent : null);
|
||||
return Daydream::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 Daydream::getResult(true, '', ['id' => $iEntryId, 'discarded' => true]);
|
||||
}
|
||||
|
||||
if($this->oDb->updateRow(self::ENTRY_TABLE, $iEntryId, $asData) === false) {
|
||||
return Daydream::getResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
return Daydream::getResult(true, '', ['entry' => $this->getEntryById($iEntryId), 'discarded' => false]);
|
||||
}
|
||||
|
||||
public function deleteEntry(int $iEntryId): string {
|
||||
if(!$this->oUser->isLoggedIn()) return Daydream::getJsonResult(false, Daydream::UNAUTHORIZED);
|
||||
if(!$this->ownsEntry($iEntryId)) return Daydream::getJsonResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
if(!$this->oDb->deleteRow(self::ENTRY_TABLE, $iEntryId)) return Daydream::getJsonResult(false, 'error.commit_db');
|
||||
|
||||
return Daydream::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()],
|
||||
//By when it was written, not by when it was inserted. The id is only
|
||||
//the tiebreaker for two entries begun in the same second.
|
||||
'orderBy' => ['started_on' => 'ASC', $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace Franzz\Daydream;
|
||||
|
||||
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' => '',
|
||||
'hand' => Daydream::DEFAULT_HAND,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
];
|
||||
|
||||
//Session & Cookie
|
||||
private const SESSION_ID_USER = 'id_user';
|
||||
private const COOKIE_TOKEN = 'daydream';
|
||||
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 Daydream::getResult(false, 'account.name_required');
|
||||
if(!filter_var($sEmail, FILTER_VALIDATE_EMAIL)) return Daydream::getResult(false, 'account.invalid_email');
|
||||
if(mb_strlen($sPassword) < self::MIN_PASSWORD_LENGTH) return Daydream::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 Daydream::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,
|
||||
'hand' => Daydream::DEFAULT_HAND,
|
||||
'clearance' => self::CLEARANCE_USER
|
||||
]);
|
||||
|
||||
if($iUserId <= 0) return Daydream::getResult(false, 'error.commit_db');
|
||||
|
||||
$this->openSessionFor($iUserId, true);
|
||||
return Daydream::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 Daydream::getResult(false, 'account.invalid_credentials');
|
||||
|
||||
if($sTimezone !== '' && $sTimezone !== ($asDbUser['timezone'] ?? '')) {
|
||||
$this->oDb->updateRow(self::USER_TABLE, $iUserId, ['timezone' => $sTimezone]);
|
||||
}
|
||||
|
||||
$this->openSessionFor($iUserId, $bRemember);
|
||||
return Daydream::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 Daydream::getResult(true, 'account.logged_out');
|
||||
}
|
||||
|
||||
public function updateSettings(string $sField, string $sValue): array {
|
||||
if(!$this->isLoggedIn()) return Daydream::getResult(false, Daydream::UNAUTHORIZED);
|
||||
if(!in_array($sField, ['name', 'language', 'timezone', 'hand'], true)) return Daydream::getResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
$sValue = mb_substr(trim($sValue), 0, self::MAX_NAME_LENGTH);
|
||||
if($sField === 'name' && $sValue === '') return Daydream::getResult(false, 'account.name_required');
|
||||
if($sField === 'timezone' && !in_array($sValue, \DateTimeZone::listIdentifiers(), true)) return Daydream::getResult(false, Daydream::NOT_FOUND);
|
||||
if($sField === 'hand' && !in_array($sValue, Daydream::getHandIds(), true)) return Daydream::getResult(false, Daydream::NOT_FOUND);
|
||||
|
||||
if(!$this->oDb->updateRow(self::USER_TABLE, $this->iUserId, [$sField => $sValue])) {
|
||||
return Daydream::getResult(false, 'error.commit_db');
|
||||
}
|
||||
|
||||
$this->setUserId($this->iUserId);
|
||||
return Daydream::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' => Daydream::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');
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<div id="calendar">
|
||||
<table class="calendar_list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="7">
|
||||
<a class="calendar_direction" href="#link_prev#"><-</a>
|
||||
#current_month#
|
||||
<a class="calendar_direction" href="#link_next#">-></a>
|
||||
</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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<div id="errors" class="round_top">
|
||||
<ul>
|
||||
<!-- [PART] ERROR [START] -->
|
||||
<li>#error#</li>
|
||||
<!-- [PART] ERROR [END] -->
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<script type="text/javascript">
|
||||
oMyThoughts.pageInit = function(asHash, bFirstPage)
|
||||
{
|
||||
console.log('home init');
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
<a id="signout" href="?p=w" title="Exit" class="option">Write</a> .
|
||||
<a id="signout" href="?p=s" title="Exit" class="option">Settings</a> .
|
||||
<a id="signout" href="?p=q" title="Exit" class="option">Sign out</a>
|
||||
#calendar#
|
||||
@@ -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;">* * *</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- [PART] THOUGHT [END] -->
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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 & Clarita.
|
||||
My Thoughts Project under <a href="http://www.gnu.org/licenses/gpl.html" target="_blank">GPLv3</a> License.
|
||||
</div>
|
||||
#errors#
|
||||
@@ -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 n’a pas fait grand chose, il a juste créé un embryon de programme. Mais ce programme s’est développé lui-même. Comme l’ordinateur de David n’était pas suffisant, il a utilisé le réseau pour s’installer 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 qu’il 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 s’endort. Aucun risque de noyade.</p>'
|
||||
//+'<p>« Prélude m’avait dit qu’il désirait connaître l’amour. Les ordinateurs n’ont pas de sentiments et l’amour n’est que sentiments. Il y a bien l’amour physique, mais sans les sentiments, cela ressemble davantage à un instinct de reproduction qu’à de l’amour. Un ordinateur n’a pas ce besoin de reproduction. Et pourquoi m’avoir choisi ? »</p>'
|
||||
//+'<p>« Oui, mais rien d’exceptionnel. » David essai de se rappeler si dans la lancé de sa jeunesse fougueuse, il n’aurait pas installé une bombe logique sur les ordinateurs de l’armé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>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "daydream",
|
||||
"description": "a place for wandering thoughts.",
|
||||
"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",
|
||||
"@fontsource/architects-daughter": "^5.3.0",
|
||||
"@fontsource/indie-flower": "^5.3.0",
|
||||
"@fontsource/kalam": "^5.3.0",
|
||||
"@fontsource/patrick-hand": "^5.3.0",
|
||||
"@fontsource/shadows-into-light": "^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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
use Franzz\Daydream\Controller;
|
||||
|
||||
echo (new Controller())->handle(__FILE__, $argv ?? []);
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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",
|
||||
"hand": "Handwriting",
|
||||
"hand_sample": "The morning came"
|
||||
},
|
||||
"book": {
|
||||
"title": "Daydream",
|
||||
"tagline": "a place for wandering thoughts.",
|
||||
"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",
|
||||
"earlier": "Earlier entries",
|
||||
"later": "Later entries"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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",
|
||||
"hand": "Écriture",
|
||||
"hand_sample": "Le matin est venu"
|
||||
},
|
||||
"book": {
|
||||
"title": "Daydream",
|
||||
"tagline": "Où l'esprit vagabonde.",
|
||||
"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",
|
||||
"earlier": "Entrées précédentes",
|
||||
"later": "Entrées suivantes"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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="#fef9ed">
|
||||
<meta name="apple-mobile-web-app-title" content="[#]title[#]" />
|
||||
<link rel="icon" type="image/png" href="assets/images/icons/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="assets/images/icons/apple-touch-icon.png" />
|
||||
<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>
|
||||
@@ -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;"> </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;"> </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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -0,0 +1,310 @@
|
||||
<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="" />
|
||||
<span class="app__name">
|
||||
<!-- The app's own name, in the app's own handwriting -->
|
||||
<span class="app__wordmark">{{ consts.title }}</span>
|
||||
<span class="app__tagline">{{ lang.get('book.tagline') }}</span>
|
||||
</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>
|
||||
@@ -0,0 +1,54 @@
|
||||
//Librairies
|
||||
import 'vite/modulepreload-polyfill';
|
||||
import { applyHand } from '@scripts/hands';
|
||||
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/daydream.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});
|
||||
|
||||
//Before the first paint, so the book is never drawn in one hand and then
|
||||
//redrawn in another
|
||||
applyHand(appConfig.consts.hands, oUser.hand);
|
||||
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');
|
||||
@@ -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>
|
||||
@@ -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 mark as it was painted, on paper of its own -->
|
||||
<img class="leaf__logo" :src="sLogo" alt="Daydream" />
|
||||
<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>
|
||||
@@ -0,0 +1,885 @@
|
||||
<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;
|
||||
|
||||
//Must outlast $turn in _var.scss, or the leaf is torn away mid-flight
|
||||
const TURN_MS = 500;
|
||||
|
||||
/**
|
||||
* 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', 'user'],
|
||||
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: [],
|
||||
|
||||
//Whether the book should keep the caret in view. True while writing,
|
||||
//false once the reader has deliberately turned or jumped somewhere
|
||||
//else - otherwise the next re-measure drags them back to the page
|
||||
//being written on.
|
||||
bFollowCaret: true,
|
||||
|
||||
//An entry jumped to from the rail or the calendar. Held so that
|
||||
//"where you are" is the entry you asked for, not whichever one
|
||||
//happens to open the page it starts on.
|
||||
iFocusId: 0,
|
||||
|
||||
//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.
|
||||
*
|
||||
* Normally the entry the visible spread starts with. But an entry can
|
||||
* begin partway down a page that opens with the tail of an earlier one -
|
||||
* and when you have just clicked that entry's bookmark, the one you
|
||||
* asked for is where you are, not the one above it. So a jumped-to entry
|
||||
* holds the position for as long as it is on the spread.
|
||||
*/
|
||||
currentEntryId() {
|
||||
if((this.iFocusId > 0) && this.spreadShows(this.iFocusId)) return this.iFocusId;
|
||||
|
||||
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());
|
||||
},
|
||||
//A different hand breaks the lines in different places, so the whole
|
||||
//book has to be re-measured and re-flowed - nothing about the old
|
||||
//layout survives a change of font.
|
||||
'user.hand'() {
|
||||
this.oPaginator.reset();
|
||||
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;
|
||||
|
||||
//Re-measuring can move the caret onto a different spread, so the
|
||||
//caret is re-derived here rather than simply repainted where it was.
|
||||
this.$nextTick(() => this.syncCaret());
|
||||
},
|
||||
|
||||
//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) {
|
||||
//Typing goes into the open entry, so the book has to be showing it
|
||||
this.bFollowCaret = true;
|
||||
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;
|
||||
|
||||
//Read the focus rather than trusting the event: focusing an element
|
||||
//that already has the focus fires nothing, so on load - where the
|
||||
//book focuses itself twice - the flag would never be set and the
|
||||
//caret would stay hidden until the first keystroke.
|
||||
this.bFocused = (document.activeElement === oInput);
|
||||
|
||||
this.iSelectionStart = oInput.selectionStart;
|
||||
this.iSelectionEnd = oInput.selectionEnd;
|
||||
this.iCaretOffset = (oInput.selectionDirection === 'backward') ? oInput.selectionStart : oInput.selectionEnd;
|
||||
|
||||
//Following happens when the writing moves, not every time the caret
|
||||
//is re-read: a re-measure must not undo a page turn.
|
||||
const oPos = this.caretPosition;
|
||||
if(this.bFollowCaret && this.bFocused && 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;
|
||||
|
||||
//Putting the caret somewhere is asking to write there
|
||||
this.bFollowCaret = true;
|
||||
|
||||
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');
|
||||
|
||||
//Moving to the facing spread is a page turn and looks like one. A
|
||||
//jump across the book - to a bookmark, a date, or the writing on
|
||||
//load - is not, so it does not pretend to be. (One page per view has
|
||||
//no gutter to hinge on; the template gives it a slide instead.)
|
||||
const bTurn = (Math.abs(iNext - iFrom) === 1);
|
||||
|
||||
//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++;
|
||||
|
||||
this.asTurn = bTurn ? {
|
||||
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)
|
||||
} : null;
|
||||
|
||||
this.iSpread = iNext;
|
||||
|
||||
clearTimeout(this.oTurnTimer);
|
||||
|
||||
if(bTurn) {
|
||||
this.oTurnTimer = setTimeout(() => {
|
||||
this.asTurn = null;
|
||||
|
||||
//The overlay must be redrawn once the leaf is gone: while it
|
||||
//was flying, one side of the spread was showing the page it
|
||||
//lifted from, so the caret had no page to be measured
|
||||
//against and was left undrawn.
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
}, TURN_MS);
|
||||
}
|
||||
|
||||
this.$nextTick(() => this.paintOverlay());
|
||||
},
|
||||
|
||||
spreadShows(iEntryId) {
|
||||
return this.visiblePages.some((oPage) => oPage.lines.some((oLine) => oLine.id === iEntryId));
|
||||
},
|
||||
|
||||
sideFor(iPage) {
|
||||
return ((iPage % 2) === 0) ? 'left' : 'right';
|
||||
},
|
||||
|
||||
async turn(iDelta) {
|
||||
//Turning the page by hand means you are reading the book again, not
|
||||
//sitting on the entry you jumped to - and not being pulled back to
|
||||
//the writing by the next re-measure.
|
||||
this.iFocusId = 0;
|
||||
this.bFollowCaret = false;
|
||||
|
||||
//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.iFocusId = iEntryId;
|
||||
this.bFollowCaret = false;
|
||||
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 shadow the leaf throws onto the page it is coming down
|
||||
onto, so that page reads as lying underneath rather than as
|
||||
a second column of text beside it. -->
|
||||
<span
|
||||
v-if="asTurn && (iPagesPerView > 1)"
|
||||
:key="'cast' + asTurn.id"
|
||||
class="book__cast"
|
||||
:class="'book__cast--' + asTurn.dir"
|
||||
></span>
|
||||
|
||||
<!-- 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { formatShortDate, formatTime, getDayKey } from '@scripts/time';
|
||||
|
||||
/* The dyes, matching $tabs in _color.scss - the three ribbons in the logo, and
|
||||
* the same three dyed deeper.
|
||||
*
|
||||
* Chosen by the day an entry was written rather than by its position, so the
|
||||
* colour means something: entries from one day share a tab colour, and a run of
|
||||
* the same colour down the rail is a day you wrote a lot. Position-based colour
|
||||
* would look the same and say nothing. */
|
||||
const TAB_DYES = ['#eab469', '#a2b097', '#c7a4b8', '#bc781a', '#697a5c', '#85526f'];
|
||||
|
||||
/**
|
||||
* A stable dye for a day.
|
||||
*
|
||||
* The day key is turned into a number and taken modulo the palette, so the same
|
||||
* date always gets the same colour - on every reload, in every session, for
|
||||
* however far back the book goes. Consecutive days land on different dyes,
|
||||
* which is the whole point of dyeing them.
|
||||
*/
|
||||
function dyeForDay(sDay) {
|
||||
//YYYY-MM-DD -> a day number. Not calendar-exact, and it does not need to
|
||||
//be: it only has to be stable and to step by one from one day to the next.
|
||||
const asParts = sDay.split('-').map(Number);
|
||||
if(asParts.length !== 3 || asParts.some(isNaN)) return TAB_DYES[0];
|
||||
|
||||
const iDays = (asParts[0] * 372) + (asParts[1] * 31) + asParts[2];
|
||||
return TAB_DYES[((iDays % TAB_DYES.length) + TAB_DYES.length) % TAB_DYES.length];
|
||||
}
|
||||
|
||||
//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'),
|
||||
dye: dyeForDay(getDayKey(oBookmark.time, oBookmark.timezone))
|
||||
}));
|
||||
},
|
||||
/**
|
||||
* 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: {
|
||||
/**
|
||||
* Walk the window one reach further back, by reading from its topmost
|
||||
* tab. At the top of what is loaded there is nothing to walk to, so this
|
||||
* fetches older entries instead.
|
||||
*/
|
||||
stepBack() {
|
||||
if(this.hasBefore) this.$emit('pick', this.shown[0].id);
|
||||
else this.$emit('load-older');
|
||||
},
|
||||
stepForward() {
|
||||
if(this.hasAfter) this.$emit('pick', this.shown.at(-1).id);
|
||||
},
|
||||
|
||||
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>
|
||||
|
||||
<!-- The book carries on above the window - or, at the top of what is
|
||||
loaded, there is more of it still to fetch. -->
|
||||
<button
|
||||
v-if="hasBefore || hasOlder"
|
||||
type="button"
|
||||
class="rail__more rail__more--before"
|
||||
:disabled="loading"
|
||||
:title="lang.get('book.earlier')"
|
||||
@click="stepBack"
|
||||
>
|
||||
<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)
|
||||
}"
|
||||
:style="{'--tab': oTab.dye, '--tab-wash': oTab.dye + '40'}"
|
||||
@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>
|
||||
|
||||
<button
|
||||
v-if="hasAfter"
|
||||
type="button"
|
||||
class="rail__more rail__more--after"
|
||||
:title="lang.get('book.later')"
|
||||
@click="stepForward"
|
||||
>
|
||||
<appIcon icon="chevronRight" size="0.8em" />
|
||||
</button>
|
||||
|
||||
<span v-if="tabs.length === 0" class="rail__empty">{{ lang.get('book.first_page') }}</span>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script>
|
||||
import appIcon from '@components/AppIcon';
|
||||
import { applyHand, loadHand } from '@scripts/hands';
|
||||
|
||||
/**
|
||||
* 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 !== '');
|
||||
}
|
||||
},
|
||||
hands() {
|
||||
return this.consts.hands || [];
|
||||
},
|
||||
languageNames() {
|
||||
const oNames = new Intl.DisplayNames([this.lang.locale], {type: 'language'});
|
||||
return this.languages.map((sCode) => ({
|
||||
code: sCode,
|
||||
label: oNames.of(sCode) || sCode
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Show the hand on the page as soon as it is picked, before the save
|
||||
* comes back - choosing a hand is a visual decision, and waiting on a
|
||||
* round trip to see it makes the picker feel broken.
|
||||
*/
|
||||
async pickHand(sHandId) {
|
||||
if(sHandId === this.user.hand) return;
|
||||
|
||||
await loadHand(this.hands, sHandId);
|
||||
applyHand(this.hands, sHandId);
|
||||
await this.set('hand', sHandId);
|
||||
|
||||
//The save is what settles it; if it failed, go back to the one the
|
||||
//account actually has rather than leaving a lie on screen.
|
||||
if(this.sError !== '') applyHand(this.hands, this.user.hand);
|
||||
},
|
||||
|
||||
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">
|
||||
<span class="paper-label">{{ lang.get('account.hand') }}</span>
|
||||
<div class="hand-picker" role="radiogroup" :aria-label="lang.get('account.hand')">
|
||||
<button
|
||||
v-for="oHand in hands"
|
||||
:key="oHand.id"
|
||||
type="button"
|
||||
class="hand-picker__option"
|
||||
:class="(oHand.id === user.hand) ? 'hand-picker__option--on' : null"
|
||||
role="radio"
|
||||
:aria-checked="oHand.id === user.hand"
|
||||
:disabled="bBusy"
|
||||
:style="{fontFamily: oHand.family + ', cursive'}"
|
||||
@click="pickHand(oHand.id)"
|
||||
>
|
||||
<!-- Written in itself: the only thing worth knowing about
|
||||
a hand is what your words look like in it. -->
|
||||
<span class="hand-picker__sample">{{ lang.get('account.hand_sample') }}</span>
|
||||
<span class="hand-picker__name">{{ oHand.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Daydream",
|
||||
"short_name": "Daydream",
|
||||
"description": "A quiet place to write. Where the mind wanders, on paper.",
|
||||
"start_url": "../../../",
|
||||
"scope": "../../../",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#fef9ed",
|
||||
"theme_color": "#fef9ed",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 298 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* The webfonts for every hand the book can be written in.
|
||||
*
|
||||
* Side-effect imports only: these declare @font-face rules and nothing more.
|
||||
* A declared face costs nothing until something is rendered in it, so shipping
|
||||
* all six is a few KB of CSS - the woff2 files are fetched only for the hand
|
||||
* actually in use, plus whatever the settings picker previews when it is opened.
|
||||
*
|
||||
* The list itself lives in Daydream::HANDS on the server, which is also what
|
||||
* validates a change - adding one there means adding its import here.
|
||||
*/
|
||||
import '@fontsource-variable/caveat/index.css';
|
||||
import '@fontsource/kalam/400.css';
|
||||
import '@fontsource/patrick-hand/400.css';
|
||||
import '@fontsource/shadows-into-light/400.css';
|
||||
import '@fontsource/architects-daughter/400.css';
|
||||
import '@fontsource/indie-flower/400.css';
|
||||
|
||||
//Fallbacks appended to whichever hand is chosen, so the book still reads as
|
||||
//handwriting if a webfont fails
|
||||
const FALLBACK = '"Segoe Script", cursive';
|
||||
|
||||
/**
|
||||
* Put a hand on the page.
|
||||
*
|
||||
* Both values are set together and read by the book's type metrics: the family
|
||||
* and the size that makes that family sit on the ruled lines. Setting one
|
||||
* without the other gives writing that floats above the rule or crashes
|
||||
* through it.
|
||||
*/
|
||||
export function applyHand(asHands, sHandId) {
|
||||
const oHand = asHands.find((oItem) => oItem.id === sHandId) || asHands[0];
|
||||
if(!oHand) return null;
|
||||
|
||||
const oRoot = document.documentElement;
|
||||
oRoot.style.setProperty('--book-hand', oHand.family + ', ' + FALLBACK);
|
||||
oRoot.style.setProperty('--book-hand-scale', String(oHand.scale));
|
||||
|
||||
return oHand;
|
||||
}
|
||||
|
||||
/** Wait for a hand's files to arrive, so the book is not measured in the fallback */
|
||||
export async function loadHand(asHands, sHandId, sSizeSample = '16px') {
|
||||
const oHand = asHands.find((oItem) => oItem.id === sHandId);
|
||||
if(!oHand || !document.fonts?.load) return;
|
||||
|
||||
try {
|
||||
await document.fonts.load(sSizeSample + ' ' + oHand.family.split(',')[0].trim());
|
||||
}
|
||||
catch{
|
||||
//A hand that will not load is not worth failing the page over - the
|
||||
//fallback stack still renders, and the next measure picks up the real
|
||||
//font whenever it does arrive.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw away every measurement taken so far.
|
||||
*
|
||||
* For a change of font, where setMetrics() cannot help: the column width,
|
||||
* the line height and the lines per page can all come back identical, so
|
||||
* the signature says nothing has changed - while every line in the book now
|
||||
* breaks somewhere else.
|
||||
*/
|
||||
reset() {
|
||||
this.asCache.clear();
|
||||
this.sSignature = '';
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@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 mark is the painting itself, carried as a layer over its own sheet: what
|
||||
* each pixel took away from the paper is its opacity, so the book arrives with
|
||||
* its colours and its shadow arrives as a shadow. It needs no plate behind it
|
||||
* and no shadow added to it - it darkens the desk here and the paper of the
|
||||
* sign-in card by exactly as much as it darkened the sheet it was painted on. */
|
||||
.app__logo {
|
||||
//Below about 80px wide the ribbons stop reading as ribbons, so the mark is
|
||||
//given the room it needs rather than shrunk to fit
|
||||
height: 3.5rem;
|
||||
width: auto;
|
||||
display: block;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.app__name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
/* The name is set in the book's own hand rather than drawn as paths: it is
|
||||
* already loaded, it stays crisp at any size, and it changes with the hand the
|
||||
* reader chose - so the masthead is written in the same writing as the pages. */
|
||||
.app__wordmark {
|
||||
font-family: var.$font-hand;
|
||||
font-size: 2rem;
|
||||
letter-spacing: 0.01em;
|
||||
color: color.$ink;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app__tagline {
|
||||
font-size: 0.8rem;
|
||||
color: color.$ink-soft;
|
||||
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;
|
||||
//Room for the desk. The book is still the app, but a book with no desk
|
||||
//around it is a page, and the mark is a book lying on something.
|
||||
padding: clamp(0.25rem, 1.2vh, 0.9rem) clamp(0.5rem, 2vw, 2rem) clamp(0.9rem, 3vh, 2.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;
|
||||
//The one thing that is allowed to be dark on the desk: the cover of the
|
||||
//book, borrowed for a strip that has to be read before it goes away
|
||||
color: color.$paper;
|
||||
background-color: color.$cover-deep;
|
||||
box-shadow: var.$shadow-panel;
|
||||
}
|
||||
|
||||
.app__notice--bad {
|
||||
background-color: color.$bad;
|
||||
}
|
||||
|
||||
.app__notice-close {
|
||||
color: rgba(254, 244, 225, 0.6);
|
||||
display: inline-flex;
|
||||
|
||||
&:hover {
|
||||
color: color.$paper;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,721 @@
|
||||
@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,
|
||||
//and the board they are bound into.
|
||||
//
|
||||
//The cover is what makes the book an object here. On the ivory desk cream
|
||||
//paper against cream is barely an edge; in the mark it is the dark brown
|
||||
//board around the paper that you actually read as a book, so the block
|
||||
//carries one - a ring of cover just past the cut edges, and the shadow it
|
||||
//throws on the desk.
|
||||
&::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 0 0.5rem color.$cover,
|
||||
0 0 0 calc(0.5rem + 1px) color.$cover-deep,
|
||||
0 1rem 1.8rem 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) * #{var.$hand-scale});
|
||||
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.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
.book__leaf--back {
|
||||
left: 0;
|
||||
transform-origin: right center;
|
||||
animation: leaf-back var.$turn 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 2.4rem rgba(20, 14, 9, 0.5), 0 0 0 1px rgba(120, 96, 62, 0.18);
|
||||
|
||||
//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);
|
||||
}
|
||||
|
||||
/* Which face you can see is switched explicitly at the upright crossing,
|
||||
* rather than left to `backface-visibility` alone.
|
||||
*
|
||||
* That property is the right tool and is set above, but engines disagree about
|
||||
* it once `preserve-3d` and a clipped ancestor are involved - and when it is
|
||||
* ignored the front face shows straight through the back one, putting both
|
||||
* pages' words on top of each other for the length of the turn. The switch
|
||||
* point is where the leaf passes 90 degrees under the easing above (measured,
|
||||
* not guessed - the rotation is not linear in time). */
|
||||
.book__leaf-face--front {
|
||||
animation: leaf-face-front var.$turn linear forwards;
|
||||
}
|
||||
|
||||
.book__leaf-face--back {
|
||||
animation: leaf-face-back var.$turn linear forwards;
|
||||
}
|
||||
|
||||
@keyframes leaf-face-front {
|
||||
0%,
|
||||
38% {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
39%,
|
||||
100% {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leaf-face-back {
|
||||
0%,
|
||||
38% {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
39%,
|
||||
100% {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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.$turn 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.$turn ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes leaf-shade-out {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes leaf-shade-in {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shadow a leaf casts on the page it is coming down onto.
|
||||
*
|
||||
* This is what stops the page underneath reading as a second page of text
|
||||
* beside the leaf: real paper coming over a page darkens it, and the eye uses
|
||||
* that to tell which sheet is on top. Sits above the pages and below the leaf.
|
||||
*/
|
||||
.book__cast {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 50%;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.book__cast--forward {
|
||||
left: 0;
|
||||
background-image: linear-gradient(to left, rgba(24, 17, 10, 0.62), rgba(24, 17, 10, 0.34) 45%, rgba(24, 17, 10, 0.16) 100%);
|
||||
animation: cast-forward var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
.book__cast--back {
|
||||
left: 50%;
|
||||
background-image: linear-gradient(to right, rgba(24, 17, 10, 0.62), rgba(24, 17, 10, 0.34) 45%, rgba(24, 17, 10, 0.16) 100%);
|
||||
animation: cast-back var.$turn cubic-bezier(0.4, 0.06, 0.3, 0.96) forwards;
|
||||
}
|
||||
|
||||
/* Deepens from the moment the leaf starts to lift - a raised sheet shades the
|
||||
* spread under it - holds through the crossing, where the page being left and
|
||||
* the page being uncovered are both in view, and lifts once the leaf has landed
|
||||
* and is itself the page being read. */
|
||||
@keyframes cast-forward {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cast-back {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
22%,
|
||||
66% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The gutter darkens under a leaf that is standing up over it */
|
||||
.book__turning .book__spine {
|
||||
animation: gutter-deepen var.$turn 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.$turn ease-out;
|
||||
}
|
||||
|
||||
.book__spread--single.book__turning--back .page {
|
||||
animation: page-in-from-left var.$turn 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.425rem);
|
||||
width: 0.85rem;
|
||||
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%);
|
||||
}
|
||||
}
|
||||
@@ -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.$amber;
|
||||
}
|
||||
|
||||
.calendar__day--current {
|
||||
color: color.$paper;
|
||||
background-color: color.$ribbon;
|
||||
|
||||
&:hover {
|
||||
background-color: color.$ribbon;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: color.$paper;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Everything here is sampled off the logo.
|
||||
*
|
||||
* The mark is an open book lying on warm ivory: a dark brown board, cream
|
||||
* pages, a hand you cannot quite read, and three dyed ribbons hanging out of
|
||||
* the fore-edge. Every value below was read out of that drawing rather than
|
||||
* guessed next to it - the sheet it is printed on is the desk, the pages are
|
||||
* the paper, the board is the ink, and the three ribbons are the only colour
|
||||
* the app is allowed to raise its voice with.
|
||||
*
|
||||
* There is no pure grey, no pure black and no blue anywhere, because there is
|
||||
* none in the mark. Every neutral here is a brown that has been let go.
|
||||
*
|
||||
* Where a dye had to carry text it is the same dye with the lightness taken
|
||||
* down - hue and saturation untouched - until it clears 4.6:1. Nothing is
|
||||
* invented; things are only ever put in shadow.
|
||||
*/
|
||||
|
||||
//The desk: the sheet the mark is printed on, the light on it, and the corners
|
||||
//falling away into the shadow the book throws
|
||||
$linen: #fef9ed;
|
||||
$linen-light: #fffdf7;
|
||||
$linen-deep: #f2e7d1;
|
||||
|
||||
//Paper. -shade is the tint towards the spine, -edge and -deep the cut edges of
|
||||
//the stack, lit and in shadow
|
||||
$paper: #fef4e1;
|
||||
$paper-shade: #fbf1dc;
|
||||
$paper-edge: #eed9b7;
|
||||
$paper-deep: #d7b990;
|
||||
|
||||
//The board the pages are bound into. Cream paper on a cream desk is a rumour;
|
||||
//in the mark it is the dark brown cover that makes the book an object
|
||||
$cover-light: #774b31;
|
||||
$cover: #5a361f;
|
||||
$cover-deep: #361d0c;
|
||||
|
||||
//Ink, in the three weights the book uses: what you wrote, what the book says
|
||||
//about it, and what is barely there. The heaviest is the brown the wordmark is
|
||||
//set in, the middle one is the lit face of the board, and the faintest is the
|
||||
//hand the mark is written in
|
||||
$ink: #45220d;
|
||||
$ink-soft: #774b31;
|
||||
$ink-faint: #a9957b;
|
||||
$ink-ghost: rgba(69, 34, 13, 0.32);
|
||||
|
||||
//Ruling. Not ink: the rules are that same handwriting brown, faded until they
|
||||
//are something the writing crosses rather than meets
|
||||
$rule: rgba(169, 149, 123, 0.42);
|
||||
$rule-margin: rgba(223, 167, 91, 0.6);
|
||||
|
||||
//The gold ribbon: anything that has to catch the eye off the paper
|
||||
$amber: #eab469;
|
||||
$amber-light: #f0bd74;
|
||||
$amber-deep: #9a6316;
|
||||
|
||||
//The green ribbon
|
||||
$sage: #a2b097;
|
||||
$sage-deep: #647458;
|
||||
|
||||
//The lilac ribbon. It marks the page being written on - gold and green already
|
||||
//have work, and this is the one the eye has nothing else to confuse with
|
||||
$ribbon: #8e5777;
|
||||
$ribbon-deep: #6f445c;
|
||||
|
||||
/* Dyed index tabs.
|
||||
*
|
||||
* Six, cycled by the day an entry was written - so every entry from one day
|
||||
* shares a colour and the rail reads as a run of days rather than a list of
|
||||
* rows. They are the mark's three ribbons exactly as they were painted, then
|
||||
* the same three in shadow, ordered so the hue changes at every step and no two
|
||||
* days running look alike.
|
||||
*/
|
||||
$tabs: #eab469, #a2b097, #c7a4b8, #bc781a, #697a5c, #85526f;
|
||||
|
||||
//Feedback. Kept in the same register as everything else - a warning belongs to
|
||||
//the palette, it does not shout over it. There is no red in the mark, so the
|
||||
//one failure colour is the deepest brown in it pulled as far towards red as it
|
||||
//can go and still belong
|
||||
$ok: #647458;
|
||||
$warn: #b37319;
|
||||
$bad: #9b4631;
|
||||
|
||||
//Overlays. The book's own shadow on the sheet: warm, and never heavier than it
|
||||
//is in the mark
|
||||
$veil: rgba(54, 29, 12, 0.5);
|
||||
$shadow-soft: rgba(90, 54, 31, 0.12);
|
||||
$shadow-deep: rgba(54, 29, 12, 0.22);
|
||||
@@ -0,0 +1,158 @@
|
||||
@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.$linen;
|
||||
|
||||
/* The desk the mark is printed on: warm ivory, lit where the book lies.
|
||||
*
|
||||
* Three layers, front to back: the pool of light the book sits in, the same
|
||||
* warmth spread wide enough to keep the middle of the screen from going flat,
|
||||
* and the corners falling away into the deeper cream of the paper stack.
|
||||
*
|
||||
* All gradients, no bitmap - it costs nothing, resizes to any screen, and
|
||||
* stays clean on a retina display where a photograph would not. */
|
||||
background-image:
|
||||
//The light the book lies in
|
||||
radial-gradient(ellipse 62% 52% at 50% 40%, #{color.$linen-light} 0%, rgba(255, 253, 247, 0) 72%),
|
||||
|
||||
//The warmth of that light, carried further out
|
||||
radial-gradient(ellipse 96% 78% at 50% 46%, rgba(255, 253, 247, 0.6) 0%, rgba(255, 253, 247, 0) 78%),
|
||||
|
||||
//The corners of the desk, where the light does not reach
|
||||
radial-gradient(ellipse 118% 96% at 50% 44%, rgba(242, 231, 209, 0) 58%, #{color.$linen-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.$amber-deep;
|
||||
}
|
||||
|
||||
/* Buttons that sit on the desk rather than on the paper: a card of the same
|
||||
* paper as the book, laid on the ivory. Restrained - they are furniture */
|
||||
.desk-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var.$text-spacing;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: var.$block-radius;
|
||||
color: color.$ink-soft;
|
||||
background-color: rgba(254, 244, 225, 0.55);
|
||||
border: 1px solid color.$paper-edge;
|
||||
transition: background-color var.$trans-quick, color var.$trans-quick, border-color var.$trans-quick;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: color.$ink;
|
||||
background-color: color.$paper;
|
||||
border-color: color.$paper-deep;
|
||||
}
|
||||
|
||||
&[aria-expanded="true"],
|
||||
&.is-active {
|
||||
color: color.$ink;
|
||||
background-color: color.$paper-shade;
|
||||
border-color: color.$paper-deep;
|
||||
}
|
||||
|
||||
&: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(142, 87, 119, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.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.$amber;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
@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(54, 29, 12, 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%;
|
||||
}
|
||||
|
||||
/* The drawer is the inside of the back board, so the two bits of text that
|
||||
* sit on it rather than on a tab have to come off the paper and onto the
|
||||
* cover - everywhere else they are ink on ivory */
|
||||
.rail__empty {
|
||||
writing-mode: horizontal-tb;
|
||||
color: rgba(254, 244, 225, 0.55);
|
||||
}
|
||||
|
||||
.rail__more {
|
||||
color: rgba(254, 244, 225, 0.6);
|
||||
|
||||
&:hover {
|
||||
color: color.$paper;
|
||||
background-color: rgba(254, 244, 225, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
@use "sass:color" as sass-color;
|
||||
@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;
|
||||
//The gold ribbon with the wordmark brown on it, exactly as the two sit
|
||||
//together in the mark. Gold is the palette's one colour for things that
|
||||
//have to be reached for; the lilac ribbon belongs to the book
|
||||
color: color.$ink;
|
||||
background-color: color.$amber;
|
||||
box-shadow: var.$shadow-elem;
|
||||
transition: background-color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible {
|
||||
background-color: sass-color.adjust(color.$amber, $lightness: 6%);
|
||||
}
|
||||
|
||||
&: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.$bad;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: color.$bad;
|
||||
background-color: rgba(155, 70, 49, 0.09);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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: color.$ink-faint;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
transition: color var.$trans-quick;
|
||||
}
|
||||
|
||||
.saver--saving,
|
||||
.saver--pending {
|
||||
color: color.$ink-soft;
|
||||
}
|
||||
|
||||
.saver--failed {
|
||||
color: color.$bad;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Picking the hand the book is written in.
|
||||
*
|
||||
* Each option is set in the hand it offers, because the name of a typeface
|
||||
* tells you nothing - what your own words look like in it is the whole
|
||||
* decision. The sample line comes from the dictionary, so it reads as a
|
||||
* sentence rather than as "Aa Bb Cc" in whatever language.
|
||||
*/
|
||||
.hand-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hand-picker__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: 1px solid color.$rule;
|
||||
border-radius: var.$block-radius;
|
||||
background-color: color.$paper;
|
||||
color: color.$ink;
|
||||
transition: border-color var.$trans-quick, background-color var.$trans-quick;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: color.$ink-faint;
|
||||
background-color: color.$paper-shade;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
//The one in use: a ruled line under the sample, like the page itself
|
||||
.hand-picker__option--on {
|
||||
border-color: color.$amber;
|
||||
background-color: color.$paper-shade;
|
||||
box-shadow: inset 0 0 0 1px color.$amber;
|
||||
}
|
||||
|
||||
.hand-picker__sample {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
//The name is the only part not set in the hand - it is a label, not a sample
|
||||
.hand-picker__name {
|
||||
font-family: var.$font-ui;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: color.$ink-faint;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
@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;
|
||||
//The window is small and centred on where the reading is, so the current
|
||||
//tab sits at eye level rather than wherever a full list would put it.
|
||||
justify-content: center;
|
||||
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)),
|
||||
//The dye, washing in from the outer edge
|
||||
linear-gradient(to right, transparent 30%, var(--tab-wash, transparent) 100%);
|
||||
|
||||
//`--tab` is set per tab from the day the entry was written, so a day's
|
||||
//entries share a colour and the rail reads as a run of days rather than a
|
||||
//list of rows. The card is still paper - only its edge is properly dyed.
|
||||
border-right: 0.45rem solid var(--tab, #{color.$paper-deep});
|
||||
border-radius: 0 var.$block-radius var.$block-radius 0;
|
||||
box-shadow: 0.12rem 0.15rem 0.5rem rgba(90, 54, 31, 0.22);
|
||||
|
||||
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(90, 54, 31, 0.3);
|
||||
//The one being read is pulled clear of the others, and its paper is clean -
|
||||
//the dye stays on the edge so the preview text is easiest to read here
|
||||
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));
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateX(0.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* The entry still being written */
|
||||
.rail__tab--open {
|
||||
box-shadow: 0.12rem 0.15rem 0.5rem rgba(90, 54, 31, 0.26), inset -0.2rem 0 0 color.$ribbon;
|
||||
}
|
||||
|
||||
.rail__tab--current.rail__tab--open {
|
||||
box-shadow: 0.12rem 0.15rem 0.6rem rgba(90, 54, 31, 0.3), 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: color.$ink-faint;
|
||||
padding: 0.5rem 0.75rem;
|
||||
writing-mode: vertical-rl;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The rail shows a few tabs either side of where the reading is. These two
|
||||
* say the book carries on past them, and walk the window along. */
|
||||
.rail__more {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
margin-left: 0;
|
||||
padding: 0.3rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: color.$ink-soft;
|
||||
border-radius: var.$block-radius;
|
||||
|
||||
&:hover {
|
||||
color: color.$ink;
|
||||
background-color: rgba(254, 244, 225, 0.6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@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
|
||||
//The hand the book is written in, chosen in settings and set on :root by
|
||||
//hands.js. The literal here is only what shows before that runs.
|
||||
$font-hand: var(--book-hand, "Caveat Variable", "Caveat", "Segoe Script", cursive);
|
||||
|
||||
//Type size as a fraction of one ruled line. Travels with the hand, since each
|
||||
//has its own x-height for the same em.
|
||||
$hand-scale: var(--book-hand-scale, 0.82);
|
||||
$font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
//Transitions
|
||||
$trans-quick: 160ms;
|
||||
$trans-mid: 280ms;
|
||||
$trans-slow: 600ms;
|
||||
|
||||
//A page turn. Short on purpose: while a leaf is upright both the page it left
|
||||
//and the page it is landing on are legible at once, and the longer that lasts
|
||||
//the more it reads as two pages muddled together rather than one turning.
|
||||
$turn: 430ms;
|
||||
|
||||
//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;
|
||||
@@ -0,0 +1,17 @@
|
||||
/* 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. */
|
||||