v3 init push

This commit is contained in:
2026-09-03 18:28:33 +02:00
parent f719cb2989
commit 549a7e0fa0
111 changed files with 8736 additions and 4873 deletions
+306
View File
@@ -0,0 +1,306 @@
<script>
import appIcon from '@components/AppIcon';
import authPanel from '@components/AuthPanel';
import book from '@components/Book';
import bookmarkRail from '@components/BookmarkRail';
import calendarWidget from '@components/CalendarWidget';
import saveIndicator from '@components/SaveIndicator';
import settingsPanel from '@components/SettingsPanel';
import sLogo from '@images/logo.png';
/**
* The desk: the book, the tabs down its side, and the few controls that are not
* part of the book itself.
*
* This also owns the one piece of lifecycle that cannot live in a component:
* sealing the open entry when the page goes away. It has to be a beacon - a
* fetch issued during unload is cancelled along with the document - and it
* closes the entry only, never the login.
*/
export default {
components: {
appIcon,
authPanel,
book,
bookmarkRail,
calendarWidget,
saveIndicator,
settingsPanel
},
inject: ['api', 'consts', 'journal', 'lang', 'user'],
data() {
return {
sLogo,
bLoaded: false,
bCalendarOpen: false,
bSettingsOpen: false,
bMenuOpen: false,
bRailOpen: false,
iCurrentId: 0,
sCurrentDay: '',
sNotice: '',
bNoticeBad: false
};
},
computed: {
signedIn() {
return (this.user.id > 0);
},
days() {
return this.journal.days;
}
},
watch: {
signedIn: {
immediate: true,
handler(bSignedIn) {
if(bSignedIn) this.openBook();
}
}
},
mounted() {
//pagehide is the one that fires reliably on mobile; beforeunload does
//not on iOS, and visibilitychange fires on every tab switch.
window.addEventListener('pagehide', this.onLeave);
window.addEventListener('beforeunload', this.onLeave);
document.addEventListener('visibilitychange', this.onVisibilityChange);
document.addEventListener('keydown', this.onKeydown);
},
beforeUnmount() {
window.removeEventListener('pagehide', this.onLeave);
window.removeEventListener('beforeunload', this.onLeave);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
document.removeEventListener('keydown', this.onKeydown);
},
methods: {
async openBook() {
try {
await this.journal.load();
//Arriving at the book should mean you can just write - so the
//page being written on is claimed up front. An entry nothing was
//written in is discarded when it closes, so this costs nothing.
if(this.journal.openId === 0) await this.journal.startWriting();
this.bLoaded = true;
this.$nextTick(() => this.$refs.book?.goToWriting());
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
onReading({id, day}) {
this.iCurrentId = id;
this.sCurrentDay = day;
},
/* Getting about */
async onPickBookmark(iEntryId) {
this.bRailOpen = false;
await this.$refs.book?.goToEntry(iEntryId);
},
async onPickDate(sDay) {
this.bCalendarOpen = false;
try {
const iEntryId = await this.journal.findEntryAtDate(sDay);
if(iEntryId > 0) await this.$refs.book?.goToEntry(iEntryId);
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
async onLoadOlder() {
try {
await this.$refs.book?.extendBackwards();
}
catch(oError) {
this.notify(oError.desc_lang_text || oError.message, true);
}
},
goToWriting() {
this.$refs.book?.goToWriting();
},
/* Account */
onSignedIn(asUser) {
Object.assign(this.user, asUser);
},
async onSignedOut() {
this.bSettingsOpen = false;
this.bMenuOpen = false;
//Everything about the previous book has to go, or the next reader
//would be handed its pages.
this.journal.entries = [];
this.journal.bookmarks = [];
this.journal.openId = 0;
this.bLoaded = false;
Object.assign(this.user, {id: 0, name: '', email: ''});
},
/* Leaving */
onLeave() {
//Whatever is on the page stays as an entry; the login is untouched.
this.journal.closeOnUnload();
},
onVisibilityChange() {
//A backgrounded tab may never come back, so flush before it goes.
if(document.visibilityState === 'hidden' && this.journal.isDirty) this.journal.save();
},
onKeydown(oEvent) {
if(oEvent.key !== 'Escape') return;
if(this.bCalendarOpen) this.bCalendarOpen = false;
else if(this.bMenuOpen) this.bMenuOpen = false;
else if(this.bRailOpen) this.bRailOpen = false;
},
notify(sMessage, bBad = false) {
this.sNotice = sMessage;
this.bNoticeBad = bBad;
},
closeMenus() {
this.bCalendarOpen = false;
this.bMenuOpen = false;
}
}
};
</script>
<template>
<div class="app">
<header class="app__header">
<h1 class="app__brand">
<img class="app__logo" :src="sLogo" :alt="consts.title" />
<span class="app__tagline">{{ lang.get('book.tagline') }}</span>
</h1>
<div class="app__tools">
<saveIndicator v-if="signedIn && bLoaded" />
<template v-if="signedIn">
<button
type="button"
class="desk-button"
:title="lang.get('book.go_to_writing')"
@click="goToWriting"
>
<appIcon icon="pen" />
</button>
<div class="app__popover-anchor">
<button
type="button"
class="desk-button"
:aria-expanded="bCalendarOpen"
:title="lang.get('action.calendar')"
@click="bCalendarOpen = !bCalendarOpen; bMenuOpen = false"
>
<appIcon icon="calendar" />
</button>
<div v-if="bCalendarOpen" class="app__popover">
<calendarWidget
:days="days"
:current-day="sCurrentDay"
@pick="onPickDate"
/>
</div>
</div>
<button
type="button"
class="desk-button desk-button--icon app__rail-toggle"
:title="lang.get('book.bookmarks')"
@click="bRailOpen = !bRailOpen"
>
<appIcon icon="bookmark" />
</button>
<div class="app__popover-anchor">
<button
type="button"
class="desk-button"
:aria-expanded="bMenuOpen"
:title="lang.get('account.settings')"
@click="bMenuOpen = !bMenuOpen; bCalendarOpen = false"
>
<appIcon icon="user" />
</button>
<div v-if="bMenuOpen" class="app__popover account-menu">
<div class="account-menu__who">
<div class="account-menu__name">{{ user.name }}</div>
<div class="account-menu__email">{{ user.email }}</div>
</div>
<button
type="button"
class="account-menu__item"
@click="bSettingsOpen = true; bMenuOpen = false"
>
<appIcon icon="settings" />
<span>{{ lang.get('account.settings') }}</span>
</button>
</div>
</div>
</template>
</div>
</header>
<main class="app__desk" @click="closeMenus">
<div class="app__book">
<book
v-if="signedIn && bLoaded"
ref="book"
@reading="onReading"
/>
</div>
<div
v-if="signedIn && bLoaded"
class="app__rail"
:class="bRailOpen ? 'app__rail--open' : null"
>
<bookmarkRail
:bookmarks="journal.bookmarks"
:current-id="iCurrentId"
:open-id="journal.openId"
:has-older="journal.hasOlder"
:loading="journal.loading"
@pick="onPickBookmark"
@load-older="onLoadOlder"
/>
</div>
</main>
<div v-if="sNotice" class="app__notice" :class="bNoticeBad ? 'app__notice--bad' : null" role="status">
<appIcon :icon="bNoticeBad ? 'alert' : 'check'" />
<span>{{ sNotice }}</span>
<button type="button" class="app__notice-close" @click="sNotice = ''">
<appIcon icon="close" size="0.9em" />
</button>
</div>
<authPanel v-if="!signedIn" @done="onSignedIn" />
<settingsPanel
v-if="bSettingsOpen"
@close="bSettingsOpen = false"
@signed-out="onSignedOut"
/>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
//Librairies
import 'vite/modulepreload-polyfill';
import Api from '@scripts/api';
import Journal from '@scripts/journal';
import Lang from '@scripts/lang';
import { getBrowserTimezone } from '@scripts/time';
import { createApp, reactive } from 'vue';
//Main template
import App from './App.vue';
//Style
import '@styles/mythoughts.scss';
//App Configuration from PHP
const appConfig = JSON.parse(document.getElementById('app-config').textContent);
//Instances
const oLang = new Lang({translations: appConfig.consts.lang, prefix: appConfig.consts.lang_prefix});
//The locale lives in the dictionary rather than in a const, so a new language
//file brings its own date formatting with it.
oLang.locale = (oLang.get('meta.locale') || 'en').replace('_', '-');
//The browser's zone is the one thing PHP cannot know before the first request,
//so every call carries it and the server formats no dates at all.
const sTimezone = getBrowserTimezone() || appConfig.user.timezone || appConfig.consts.default_timezone;
const oApi = new Api({
server: appConfig.consts.server,
processPage: appConfig.consts.process_page,
timezone: sTimezone,
csrfToken: appConfig.consts.csrf_token,
errorCode: appConfig.consts.error,
lang: oLang
});
const oUser = reactive({...appConfig.user});
const oJournal = reactive(new Journal(oApi, appConfig.consts));
//Mount app
const oApp = createApp(App);
oApp.provide('api', oApi);
oApp.provide('consts', appConfig.consts);
oApp.provide('journal', oJournal);
oApp.provide('lang', oLang);
oApp.provide('timezone', sTimezone);
oApp.provide('user', oUser);
oApp.mount('#container');
+44
View File
@@ -0,0 +1,44 @@
<script>
import { getIconPaths, hasIcon } from '@scripts/icons';
export default {
props: {
icon: String,
size: {type: String, default: '1.15em'},
title: String
},
computed: {
paths() {
if(!hasIcon(this.icon)) console.warn('Missing icon:', this.icon);
return getIconPaths(this.icon);
}
}
};
</script>
<template>
<svg
class="app-icon"
:class="'app-icon--' + icon"
:style="{width: size, height: size}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
:role="title ? 'img' : 'presentation'"
:aria-hidden="title ? null : 'true'"
>
<title v-if="title">{{ title }}</title>
<path v-for="(sPath, iIndex) in paths" :key="iIndex" :d="sPath" />
</svg>
</template>
<style lang="scss">
.app-icon {
flex: 0 0 auto;
display: block;
overflow: visible;
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script>
import appIcon from '@components/AppIcon';
import sLogo from '@images/logo.png';
/**
* The closed book: sign in, or start a new one.
*
* The two modes are one form because they differ by a single field - splitting
* them into separate components would duplicate the whole submit path for the
* sake of a name input.
*/
export default {
components: {
appIcon
},
emits: ['done'],
inject: ['api', 'consts', 'lang', 'timezone'],
data() {
return {
sLogo,
bNewBook: false,
sName: '',
sEmail: '',
sPassword: '',
bRemember: true,
sError: '',
bBusy: false
};
},
computed: {
canSubmit() {
return !this.bBusy && (this.sEmail.trim() !== '') && (this.sPassword !== '') && (!this.bNewBook || (this.sName.trim() !== ''));
}
},
mounted() {
this.$refs.first?.focus();
},
methods: {
toggleMode() {
this.bNewBook = !this.bNewBook;
this.sError = '';
this.$nextTick(() => this.$refs.first?.focus());
},
async submit() {
if(!this.canSubmit) return;
this.bBusy = true;
this.sError = '';
try {
const asData = this.bNewBook ?
await this.api.post('signup', {
name: this.sName,
email: this.sEmail,
password: this.sPassword,
t: this.timezone
})
:
await this.api.post('login', {
email: this.sEmail,
password: this.sPassword,
remember: this.bRemember ? 1 : 0,
t: this.timezone
});
//Nothing typed here should outlive the successful submit.
this.sPassword = '';
this.$emit('done', asData.user);
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
this.sPassword = '';
this.$refs.password?.focus();
}
finally {
this.bBusy = false;
}
}
}
};
</script>
<template>
<div class="veil">
<form class="leaf" @submit.prevent="submit">
<div class="leaf__head">
<!-- Paper at last: the logo's own colours, as it was drawn -->
<img class="leaf__logo" :src="sLogo" alt="MyThoughts" />
<h2 class="leaf__title">
{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}
</h2>
<p class="leaf__sub">{{ lang.get('book.tagline') }}</p>
</div>
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
<div v-if="bNewBook" class="leaf__row">
<label class="paper-label" for="auth-name">{{ lang.get('account.name') }}</label>
<input
id="auth-name"
ref="first"
v-model="sName"
class="paper-field"
type="text"
autocomplete="name"
maxlength="100"
required
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="auth-email">{{ lang.get('account.email') }}</label>
<input
id="auth-email"
:ref="bNewBook ? null : 'first'"
v-model="sEmail"
class="paper-field"
type="email"
autocomplete="email"
required
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="auth-password">{{ lang.get('account.password') }}</label>
<input
id="auth-password"
ref="password"
v-model="sPassword"
class="paper-field"
type="password"
:autocomplete="bNewBook ? 'new-password' : 'current-password'"
required
/>
</div>
<label v-if="!bNewBook" class="leaf__check">
<input v-model="bRemember" type="checkbox" />
<span>{{ lang.get('account.remember') }}</span>
</label>
<div class="leaf__actions">
<button type="submit" class="ink-button" :disabled="!canSubmit">
<appIcon :icon="bNewBook ? 'pen' : 'book'" />
<span>{{ bNewBook ? lang.get('account.sign_up') : lang.get('account.sign_in') }}</span>
</button>
</div>
<p class="leaf__note">
<button type="button" class="text-button" @click="toggleMode">
{{ bNewBook ? lang.get('account.have_account') : lang.get('account.no_account') }}
</button>
</p>
</form>
</div>
</template>
+807
View File
@@ -0,0 +1,807 @@
<script>
import bookPage from '@components/BookPage';
import appIcon from '@components/AppIcon';
import Paginator from '@scripts/paginator';
import { getDayKey } from '@scripts/time';
//Matches $mobile in _var.scss: below this a spread cannot hold two pages, so
//the book paginates one page per view instead.
const SINGLE_PAGE_WIDTH = 860;
const TURN_MS = 700;
/**
* The open book.
*
* Three things meet here and nowhere else:
*
* - Measurement. One hidden element, laid out by the browser at the exact width
* of a real text column, decides where every line breaks. Nothing guesses at
* text metrics.
* - Flow. The paginator turns the journal into visual lines, the lines into
* pages, and the pages into spreads - so an entry runs off the left page onto
* the right one and on into the next spread, and a new entry simply starts on
* the line after the last one ended.
* - Writing. A textarea cannot flow across two columns, so it is not what you
* look at: it holds the keystrokes, the selection and the arrow keys, while
* the book draws the glyphs, the caret and the selection itself. It is sized
* to exactly one column, which makes its own soft wrapping agree with the
* paginator's - so Up and Down still move by the lines you can see.
*/
export default {
components: {
appIcon,
bookPage
},
emits: ['reading'],
inject: ['journal', 'lang'],
data() {
return {
iSpread: 0,
iPagesPerView: 2,
asLayout: {pages: [[]], index: new Map(), lineCount: 0, linesPerPage: 1},
iLineHeight: 0,
iLinesPerPage: 0,
//Geometry of the text column the input and the measurer sit on,
//relative to the spread
asTextBox: {left: 0, top: 0, width: 0, height: 0},
iCaretOffset: 0,
iSelectionStart: 0,
iSelectionEnd: 0,
bFocused: false,
asCaret: null,
asSelection: [],
//The leaf in flight: {id, dir, front, back, from} while a page turns
asTurn: null,
iTurnId: 0,
bReady: false
};
},
computed: {
entriesById() {
const asEntries = new Map();
for(const oEntry of this.journal.entries) asEntries.set(oEntry.id, oEntry);
return asEntries;
},
pages() {
const aoPages = this.asLayout.pages || [[]];
//The paginator pads to an even number of pages so a spread is always
//full. One page per view has no facing page to fill, so the padding
//would just be a blank screen at the end of the book.
if((this.iPagesPerView === 1) && (aoPages.length > 1) && (aoPages.at(-1).length === 0)) {
return aoPages.slice(0, -1);
}
return aoPages;
},
//Pages are uniform chunks, so a flat line index maps straight back to a
//page - which is what makes "where is the caret" a lookup, not a walk.
flatLines() {
return this.pages.flat();
},
spreadCount() {
return Math.max(1, Math.ceil(this.pages.length / this.iPagesPerView));
},
visiblePages() {
const iFirst = this.iSpread * this.iPagesPerView;
const aoVisible = [];
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
const iPage = iFirst + iOffset;
aoVisible.push({index: iPage, lines: this.pages[iPage] || []});
}
//Mid-turn, the half the leaf lifted from still shows the page it
//lifted from - it is only covered when the leaf lands on it. So the
//spread underneath is a mix: one side already turned, one not yet.
if(this.asTurn && (this.iPagesPerView > 1)) {
const iStale = (this.asTurn.dir === 'forward') ? 0 : 1;
const iStalePage = (this.asTurn.from * this.iPagesPerView) + iStale;
aoVisible[iStale] = {index: iStalePage, lines: this.pages[iStalePage] || []};
}
return aoVisible;
},
openEntry() {
return this.journal.openEntry;
},
entryCount() {
return this.journal.entries.length;
},
caretPosition() {
const oOpen = this.openEntry;
if(!oOpen) return null;
const iOffset = Math.max(0, Math.min(this.iCaretOffset, (oOpen.content || '').length));
const asLines = this.flatLines;
let iFound = -1;
for(let iLine = 0; iLine < asLines.length; iLine++) {
const oLine = asLines[iLine];
if(oLine.id !== oOpen.id) continue;
//Lines of the open entry are contiguous and in order, so the
//last one starting at or before the caret is the caret's line.
if(oLine.start <= iOffset) iFound = iLine;
else break;
}
if(iFound < 0) return null;
const iPerPage = Math.max(1, this.asLayout.linesPerPage);
const iPage = Math.floor(iFound / iPerPage);
return {
global: iFound,
page: iPage,
line: iFound % iPerPage,
spread: Math.floor(iPage / this.iPagesPerView),
offset: iOffset
};
},
//What the rail highlights and the calendar opens on: the entry the
//visible spread starts with.
currentEntryId() {
for(const oPage of this.visiblePages) {
if(oPage.lines.length > 0) return oPage.lines[0].id;
}
return this.journal.openId;
},
currentDay() {
const oEntry = this.entriesById.get(this.currentEntryId);
return oEntry ? getDayKey(oEntry.time, oEntry.timezone) : '';
},
canTurnBack() {
return (this.iSpread > 0) || this.journal.hasOlder;
},
canTurnForward() {
return (this.iSpread < this.spreadCount - 1) || this.journal.hasNewer;
},
//The ribbon marks the spread being written on
showRibbon() {
const oPos = this.caretPosition;
return (this.iPagesPerView > 1) && (oPos !== null) && (oPos.spread === this.iSpread);
},
measureStyle() {
return {
left: this.asTextBox.left + 'px',
top: this.asTextBox.top + 'px',
width: this.asTextBox.width + 'px'
};
},
inputStyle() {
return {
left: this.asTextBox.left + 'px',
top: this.asTextBox.top + 'px',
width: this.asTextBox.width + 'px',
height: this.asTextBox.height + 'px'
};
}
},
watch: {
//A new entry was opened, or the book was reloaded around a date
'journal.openId'() {
this.$nextTick(() => this.refresh());
},
entryCount() {
this.relayout();
this.$nextTick(() => this.paintOverlay());
},
iPagesPerView() {
this.$nextTick(() => this.measure());
},
//The rail highlights, and the calendar opens on, whatever is being read
currentEntryId: {
immediate: true,
handler(iEntryId) {
this.$emit('reading', {id: iEntryId, day: this.currentDay});
}
}
},
created() {
//None of these are state the template renders, and the paginator holds
//DOM nodes and a cache - proxying them would cost on every keystroke.
this.oPaginator = new Paginator();
this.oRange = document.createRange();
this.oResizeObserver = null;
this.oTurnTimer = null;
this.oMedia = null;
},
async mounted() {
this.oMedia = window.matchMedia('(max-width: ' + SINGLE_PAGE_WIDTH + 'px)');
this.iPagesPerView = this.oMedia.matches ? 1 : 2;
this.oMedia.addEventListener('change', this.onMediaChange);
//The paginator measures whatever font is actually loaded, so measuring
//before the hand arrives would lay the whole book out in the fallback.
if(document.fonts?.ready) await document.fonts.ready;
await this.measure();
this.oResizeObserver = new ResizeObserver(() => this.measure());
this.oResizeObserver.observe(this.$refs.book);
document.addEventListener('selectionchange', this.onSelectionChange);
this.refresh();
this.goToWriting();
},
beforeUnmount() {
this.oResizeObserver?.disconnect();
this.oMedia?.removeEventListener('change', this.onMediaChange);
document.removeEventListener('selectionchange', this.onSelectionChange);
clearTimeout(this.oTurnTimer);
},
methods: {
onMediaChange(oEvent) {
this.iPagesPerView = oEvent.matches ? 1 : 2;
},
/* Measuring */
/**
* Resolve the two numbers the whole layout hangs on - how tall a ruled
* line is, and how many of them fit on a page - and hand them to the
* paginator. Everything else follows from those.
*/
async measure() {
const oBook = this.$refs.book;
const oMeasure = this.$refs.measure;
if(!oBook || !oMeasure) return;
//The measurer has to sit at the width of a real column before its
//line height means anything.
this.readTextBox();
await this.$nextTick();
const iLineHeight = parseFloat(getComputedStyle(oMeasure).lineHeight) || 0;
if(iLineHeight <= 0) return;
const oBody = this.getPageComponent(this.iSpread * this.iPagesPerView)?.getBodyElement();
if(!oBody) return;
//Whole rules only: a page that ends on half a line looks like a bug.
const iLines = Math.max(1, Math.floor(oBody.clientHeight / iLineHeight));
this.iLineHeight = iLineHeight;
this.iLinesPerPage = iLines;
oBook.style.setProperty('--book-lines', String(iLines));
await this.$nextTick();
if(this.oPaginator.setMetrics(oMeasure, iLineHeight, iLines)) this.relayout();
this.bReady = true;
this.$nextTick(() => this.paintOverlay());
},
//Geometry of the column the input and the measurer overlay
readTextBox() {
const oSpread = this.$refs.spread;
if(!oSpread) return;
const oPos = this.caretPosition;
const iAnchor = oPos ? oPos.page : (this.iSpread * this.iPagesPerView);
const oPage = this.getPageComponent(iAnchor) || this.getPageComponent(this.iSpread * this.iPagesPerView);
const oLines = oPage?.getLinesElement();
if(!oLines) return;
const oSpreadRect = oSpread.getBoundingClientRect();
const oLinesRect = oLines.getBoundingClientRect();
this.asTextBox = {
left: oLinesRect.left - oSpreadRect.left,
top: oLinesRect.top - oSpreadRect.top,
width: oLinesRect.width,
height: Math.max(1, this.iLinesPerPage) * Math.max(1, this.iLineHeight)
};
},
relayout() {
if(!this.oPaginator.ready) return;
this.asLayout = this.oPaginator.layout(this.journal.entries);
},
/* Drawing what the input cannot */
paintOverlay() {
this.readTextBox();
const oPos = this.caretPosition;
if(oPos && this.bFocused && (oPos.spread === this.iSpread)) {
const oPage = this.getPageComponent(oPos.page);
const oLine = this.flatLines[oPos.global];
this.asCaret = (oPage && oLine) ?
{page: oPos.page, line: oPos.line, x: this.getCaretX(oPage, oPos.line, oLine, oPos.offset - oLine.start)}
:
null;
}
else this.asCaret = null;
this.asSelection = this.buildSelection();
},
getCaretX(oPage, iLine, oLine, iChars) {
const oLineEl = oPage.getLineElement(iLine);
const oLinesEl = oPage.getLinesElement();
if(!oLineEl || !oLinesEl) return 0;
const iOrigin = oLinesEl.getBoundingClientRect().left;
return this.getBoundaryX(oLineEl, iChars) - iOrigin;
},
/** Client x of the boundary `iChars` characters into a rendered line */
getBoundaryX(oLineEl, iChars) {
const oText = oLineEl.firstChild;
if(!oText || (iChars <= 0)) return oLineEl.getBoundingClientRect().left;
this.oRange.setStart(oText, 0);
this.oRange.setEnd(oText, Math.min(iChars, oText.length));
return this.oRange.getBoundingClientRect().right;
},
/**
* The input's own selection is invisible, so the book draws one rect per
* visual line the selection covers.
*/
buildSelection() {
const oOpen = this.openEntry;
if(!oOpen || (this.iSelectionStart >= this.iSelectionEnd)) return [];
const asRects = [];
const iFirstPage = this.iSpread * this.iPagesPerView;
for(let iOffset = 0; iOffset < this.iPagesPerView; iOffset++) {
const iPage = iFirstPage + iOffset;
const oPage = this.getPageComponent(iPage);
const oLinesEl = oPage?.getLinesElement();
if(!oLinesEl) continue;
const iOrigin = oLinesEl.getBoundingClientRect().left;
(this.pages[iPage] || []).forEach((oLine, iLine) => {
if(oLine.id !== oOpen.id) return;
const iFrom = Math.max(this.iSelectionStart, oLine.start);
const iTo = Math.min(this.iSelectionEnd, oLine.end);
if(iTo < iFrom) return;
const oLineEl = oPage.getLineElement(iLine);
if(!oLineEl) return;
const iLeft = this.getBoundaryX(oLineEl, iFrom - oLine.start) - iOrigin;
const iRight = this.getBoundaryX(oLineEl, iTo - oLine.start) - iOrigin;
asRects.push({
page: iPage,
line: iLine,
left: iLeft,
//A selected line break has no width of its own, but it
//was still selected - show it as a thin mark.
width: Math.max(3, iRight - iLeft)
});
});
}
return asRects;
},
caretFor(iPage) {
return (this.asCaret && (this.asCaret.page === iPage)) ? this.asCaret : null;
},
selectionFor(iPage) {
return this.asSelection.filter((oRect) => oRect.page === iPage);
},
placeholderFor(iPage) {
const oOpen = this.openEntry;
if(!oOpen || (oOpen.content !== '')) return -1;
const oPos = this.asLayout.index.get(oOpen.id);
return (oPos && (oPos.firstPage === iPage)) ? oPos.firstLineOnPage : -1;
},
isWritable(iPage) {
const oOpen = this.openEntry;
if(!oOpen) return false;
return (this.pages[iPage] || []).some((oLine) => oLine.id === oOpen.id);
},
/**
* The page component showing a given page of the book.
*
* Matched on the page it is actually displaying, not on its position in
* the ref array: Vue does not promise that a v-for ref array is in
* source order, and taking it on trust measures the caret against the
* facing page's text - which puts it somewhere else entirely on the line.
*/
getPageComponent(iPageIndex) {
const aoPages = this.$refs.pageEls;
if(!Array.isArray(aoPages)) return null;
return aoPages.find((oPage) => oPage && (oPage.pageIndex === iPageIndex)) || null;
},
/* Writing */
refresh() {
const oInput = this.$refs.input;
const oOpen = this.openEntry;
if(oInput && oOpen && (oInput.value !== oOpen.content)) {
oInput.value = oOpen.content;
oInput.setSelectionRange(oOpen.content.length, oOpen.content.length);
}
this.relayout();
this.$nextTick(() => this.syncCaret());
},
onInput(oEvent) {
this.journal.write(oEvent.target.value);
this.relayout();
this.syncCaret();
},
onSelectionChange() {
if(document.activeElement === this.$refs.input) this.syncCaret();
},
/**
* Mirror the input's caret onto the page - and, when the writing has run
* off the bottom of the spread, turn to the page it ran onto.
*/
syncCaret() {
const oInput = this.$refs.input;
if(!oInput) return;
this.iSelectionStart = oInput.selectionStart;
this.iSelectionEnd = oInput.selectionEnd;
this.iCaretOffset = (oInput.selectionDirection === 'backward') ? oInput.selectionStart : oInput.selectionEnd;
const oPos = this.caretPosition;
if(oPos && (oPos.spread !== this.iSpread)) this.setSpread(oPos.spread);
this.$nextTick(() => this.paintOverlay());
},
focusAt(iOffset, bExtend = false) {
const oInput = this.$refs.input;
if(!oInput) return;
oInput.focus({preventScroll: true});
if(bExtend) oInput.setSelectionRange(Math.min(oInput.selectionStart, iOffset), Math.max(oInput.selectionEnd, iOffset));
else oInput.setSelectionRange(iOffset, iOffset);
this.syncCaret();
},
/**
* Back to the page being written on.
*
* Following a bookmark re-centres the loaded window somewhere else in
* the book, which can leave the open entry outside it entirely - so the
* writing page has to be fetched back before it can be turned to.
*/
async goToWriting() {
let oOpen = this.openEntry;
if(!oOpen && (this.journal.openId > 0)) {
await this.journal.loadAround(this.journal.openId);
this.relayout();
await this.$nextTick();
oOpen = this.openEntry;
}
if(!oOpen) return;
this.focusAt((oOpen.content || '').length);
},
/** Clicking the paper puts the caret where you clicked */
onPick({pageIndex, lineIndex, clientX, extend}) {
const oOpen = this.openEntry;
if(!oOpen) return;
const asLines = this.pages[pageIndex] || [];
const oPage = this.getPageComponent(pageIndex);
if(!oPage) return;
let iLine = lineIndex;
let oLine = asLines[iLine];
let bExact = true;
//Clicking below the writing, or on an entry already closed, lands at
//the end of the nearest line that can actually be written on.
if(!oLine || (oLine.id !== oOpen.id)) {
bExact = false;
oLine = null;
for(let iAbove = Math.min(lineIndex, asLines.length - 1); iAbove >= 0; iAbove--) {
if(asLines[iAbove] && (asLines[iAbove].id === oOpen.id)) {
iLine = iAbove;
oLine = asLines[iAbove];
break;
}
}
}
if(!oLine) return;
const iOffset = bExact ? this.getOffsetAtX(oPage, iLine, oLine, clientX) : oLine.end;
this.focusAt(iOffset, extend);
},
/** Nearest character boundary to a click, by binary search over the line */
getOffsetAtX(oPage, iLine, oLine, iClientX) {
const oLineEl = oPage.getLineElement(iLine);
if(!oLineEl) return oLine.start;
const oText = oLineEl.firstChild;
const iLength = Math.min(oLine.end - oLine.start, oText ? oText.length : 0);
if(iLength <= 0) return oLine.start;
let iLow = 0;
let iHigh = iLength;
while(iLow < iHigh) {
const iMid = (iLow + iHigh) >> 1;
if(this.getBoundaryX(oLineEl, iMid) < iClientX) iLow = iMid + 1;
else iHigh = iMid;
}
//Land on whichever side of the character the click actually fell
if(iLow > 0) {
const iBefore = this.getBoundaryX(oLineEl, iLow - 1);
const iAfter = this.getBoundaryX(oLineEl, iLow);
if(Math.abs(iClientX - iBefore) < Math.abs(iClientX - iAfter)) iLow--;
}
return oLine.start + iLow;
},
/* Turning */
/**
* Turn to a spread, sending a leaf across the gutter to get there.
*
* A leaf has a page on each side, and the two it shows during the turn
* are not the two you end up looking at: going forward, the leaf lifts
* the old right-hand page and lands carrying the new left-hand one on
* its back. Going back it is the mirror of that.
*/
setSpread(iSpread, sDirection = '') {
const iNext = Math.max(0, Math.min(this.spreadCount - 1, iSpread));
if(iNext === this.iSpread) return;
const iFrom = this.iSpread;
const sDir = sDirection || ((iNext > iFrom) ? 'forward' : 'back');
const bForward = (sDir === 'forward');
//Keys the leaf element. Turning again before the last turn finished
//has to build a new leaf, or Vue patches the old one in place and
//the CSS animation carries on from wherever it had got to.
this.iTurnId++;
if(this.iPagesPerView > 1) {
this.asTurn = {
id: this.iTurnId,
dir: sDir,
from: iFrom,
//Front: the face you were reading. Back: what it reveals.
front: bForward ? ((iFrom * 2) + 1) : (iFrom * 2),
back: bForward ? (iNext * 2) : ((iNext * 2) + 1)
};
}
else this.asTurn = {id: this.iTurnId, dir: sDir, from: iFrom, front: -1, back: -1};
this.iSpread = iNext;
clearTimeout(this.oTurnTimer);
this.oTurnTimer = setTimeout(() => {
this.asTurn = null;
}, TURN_MS);
this.$nextTick(() => this.paintOverlay());
},
sideFor(iPage) {
return ((iPage % 2) === 0) ? 'left' : 'right';
},
async turn(iDelta) {
//Turning past either edge of the loaded window fetches more book
//before it turns, so the flow never dead-ends on a chunk boundary.
if((iDelta < 0) && (this.iSpread === 0)) {
if(this.journal.hasOlder) await this.extendBackwards();
return;
}
if((iDelta > 0) && (this.iSpread >= this.spreadCount - 1)) {
if(!this.journal.hasNewer) return;
await this.journal.loadNewer();
this.relayout();
await this.$nextTick();
this.setSpread(this.iSpread + 1, 'forward');
return;
}
this.setSpread(this.iSpread + iDelta);
},
/**
* Older entries are prepended, which shifts every page index - so the
* page being read is pinned first and restored after.
*/
async extendBackwards() {
const oAnchor = this.getAnchor();
const iAdded = await this.journal.loadOlder();
this.relayout();
await this.$nextTick();
if(oAnchor) this.restoreAnchor(oAnchor);
if(iAdded > 0) this.setSpread(this.iSpread - 1, 'back');
},
getAnchor() {
const oLine = (this.pages[this.iSpread * this.iPagesPerView] || [])[0];
return oLine ? {id: oLine.id, start: oLine.start} : null;
},
restoreAnchor(oAnchor) {
const iLine = this.flatLines.findIndex((oItem) => (oItem.id === oAnchor.id) && (oItem.start === oAnchor.start));
if(iLine < 0) return;
const iPage = Math.floor(iLine / Math.max(1, this.asLayout.linesPerPage));
this.iSpread = Math.floor(iPage / this.iPagesPerView);
},
/** Turn to an entry, fetching it first if it is outside the window */
async goToEntry(iEntryId) {
if(!this.asLayout.index.has(iEntryId)) {
await this.journal.loadAround(iEntryId);
this.relayout();
await this.$nextTick();
}
const oPos = this.asLayout.index.get(iEntryId);
if(!oPos) return;
this.setSpread(Math.floor(oPos.firstPage / this.iPagesPerView));
},
onKeydown(oEvent) {
//Only when the reader is not writing - otherwise these are just keys
if(this.bFocused) return;
if(oEvent.key === 'PageUp' || oEvent.key === 'ArrowLeft') this.turn(-1);
else if(oEvent.key === 'PageDown' || oEvent.key === 'ArrowRight') this.turn(1);
else return;
oEvent.preventDefault();
}
}
};
</script>
<template>
<div ref="book" class="book" tabindex="-1" @keydown="onKeydown">
<div class="book__shell">
<div
ref="spread"
class="book__spread"
:class="[
(iPagesPerView === 1) ? 'book__spread--single' : null,
asTurn ? 'book__turning' : null,
asTurn ? ('book__turning--' + asTurn.dir) : null
]"
>
<bookPage
v-for="oPage in visiblePages"
:key="oPage.index"
ref="pageEls"
:lines="oPage.lines"
:entries="entriesById"
:page-index="oPage.index"
:side="(oPage.index % 2 === 0) ? 'left' : 'right'"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
:writable="isWritable(oPage.index)"
:caret="caretFor(oPage.index)"
:selection="selectionFor(oPage.index)"
:placeholder-line="placeholderFor(oPage.index)"
:placeholder="lang.get('book.write_here')"
@pick="onPick"
/>
<!-- The leaf in flight. Two real pages back to back, hinged on
the gutter - the front is the page being lifted away, the
back is the one it carries into view. -->
<div
v-if="asTurn && (iPagesPerView > 1)"
:key="asTurn.id"
class="book__leaf"
:class="'book__leaf--' + asTurn.dir"
>
<div class="book__leaf-face book__leaf-face--front">
<bookPage
:lines="pages[asTurn.front] || []"
:entries="entriesById"
:page-index="asTurn.front"
:side="sideFor(asTurn.front)"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
/>
<span class="book__leaf-shade"></span>
</div>
<div class="book__leaf-face book__leaf-face--back">
<bookPage
:lines="pages[asTurn.back] || []"
:entries="entriesById"
:page-index="asTurn.back"
:side="sideFor(asTurn.back)"
:open-id="journal.openId"
:line-height="iLineHeight"
:lines-per-page="iLinesPerPage"
/>
<span class="book__leaf-shade"></span>
</div>
</div>
<span v-if="iPagesPerView > 1" class="book__spine"></span>
<span v-if="showRibbon" class="book__ribbon" style="height: 42%"></span>
<!-- Where the paginator does its measuring: one stable element,
laid out at the exact width of a real text column. -->
<div ref="measure" class="page__measure" :style="measureStyle" aria-hidden="true"></div>
<!-- The keystrokes, the selection and the arrow keys. Not the
glyphs - the pages draw those. -->
<textarea
ref="input"
class="page__input"
:style="inputStyle"
:readonly="!openEntry"
spellcheck="false"
autocapitalize="sentences"
:aria-label="lang.get('book.write_here')"
@input="onInput"
@keyup="syncCaret"
@focus="bFocused = true; paintOverlay()"
@blur="bFocused = false; paintOverlay()"
></textarea>
<button
type="button"
class="book__turner book__turner--back"
:disabled="!canTurnBack"
:title="lang.get('action.prev_page')"
@click="turn(-1)"
>
<appIcon icon="chevronLeft" size="1.4em" />
</button>
<button
type="button"
class="book__turner book__turner--forward"
:disabled="!canTurnForward"
:title="lang.get('action.next_page')"
@click="turn(1)"
>
<appIcon icon="chevronRight" size="1.4em" />
</button>
</div>
</div>
</div>
</template>
+175
View File
@@ -0,0 +1,175 @@
<script>
import { formatDate, formatTime } from '@scripts/time';
/**
* One page of the book.
*
* A page is dumb on purpose: it is handed a list of already-broken visual lines
* and draws them, the stamps in its margin, and whatever overlay (caret,
* selection) the book has measured for it. All the deciding - where lines
* break, which page they land on, where the caret is - happens in Book.vue,
* because none of it can be decided a page at a time.
*/
export default {
props: {
//Visual lines for this page: {id, start, end, first}
lines: {type: Array, required: true},
//id -> entry, for the text and the margin stamps
entries: {type: Map, required: true},
//Position of this page in the whole book, for the page number
pageIndex: {type: Number, required: true},
side: {type: String, default: 'left'},
//The entry still being written, drawn a shade darker than past pages
openId: {type: Number, default: 0},
lineHeight: {type: Number, default: 0},
linesPerPage: {type: Number, default: 1},
//Set on the page that currently holds the writing caret
writable: {type: Boolean, default: false},
//{line, x} in page coordinates, or null
caret: {type: Object, default: null},
//[{line, left, width}] - the book draws its own selection, since the
//input's native one is invisible
selection: {type: Array, default: () => []},
//Line to hang the "write here" hint on, or -1
placeholderLine: {type: Number, default: -1},
placeholder: {type: String, default: ''}
},
emits: ['pick'],
inject: ['lang'],
computed: {
//Where an entry begins, the margin says when it was written
stamps() {
const asStamps = [];
this.lines.forEach((oLine, iIndex) => {
if(!oLine.first) return;
const oEntry = this.entries.get(oLine.id);
if(!oEntry) return;
asStamps.push({
id: oLine.id,
line: iIndex,
date: formatDate(oEntry.time, oEntry.timezone, this.lang.locale),
time: formatTime(oEntry.time, oEntry.timezone, this.lang.locale)
});
});
return asStamps;
},
pageNumber() {
return this.pageIndex + 1;
}
},
methods: {
lineText(oLine) {
const oEntry = this.entries.get(oLine.id);
return oEntry ? oEntry.content.slice(oLine.start, oLine.end) : '';
},
isOpenLine(oLine) {
return (oLine.id === this.openId);
},
offsetFor(iLine) {
return (iLine * this.lineHeight) + 'px';
},
/* Read back by Book.vue, which needs real geometry to place the caret,
* the selection, the input and the measurer. */
getLinesElement() {
return this.$refs.lines || null;
},
getBodyElement() {
return this.$refs.body || null;
},
/**
* The nth rendered line.
*
* Queried from the DOM rather than read out of a `ref` on the v-for:
* Vue makes no promise that a v-for ref array is in source order, and
* when it is not, the caret gets measured against the wrong line - which
* lands it back at the start of the line instead of after what was
* just typed.
*/
getLineElement(iIndex) {
const oLines = this.$refs.lines;
if(!oLines) return null;
return oLines.querySelectorAll('.page__line')[iIndex] || null;
},
onClick(oEvent) {
if(!this.writable || (this.lineHeight <= 0)) return;
const oLines = this.$refs.lines;
if(!oLines) return;
const oRect = oLines.getBoundingClientRect();
const iLine = Math.floor((oEvent.clientY - oRect.top) / this.lineHeight);
this.$emit('pick', {
pageIndex: this.pageIndex,
lineIndex: Math.max(0, Math.min(this.linesPerPage - 1, iLine)),
clientX: oEvent.clientX,
extend: oEvent.shiftKey
});
}
}
};
</script>
<template>
<section
class="page"
:class="['page--' + side, writable ? 'page--writable' : null]"
@mousedown.prevent="onClick"
>
<div ref="body" class="page__body">
<div class="page__margin">
<span
v-for="oStamp in stamps"
:key="oStamp.id"
class="page__stamp"
:style="{top: offsetFor(oStamp.line)}"
>
<span class="page__stamp-date">{{ oStamp.date }}</span>
<span class="page__stamp-time">{{ oStamp.time }}</span>
</span>
</div>
<div class="page__column">
<div ref="lines" class="page__lines">
<span
v-for="(oSelection, iIndex) in selection"
:key="'sel' + iIndex"
class="page__selection"
:style="{top: offsetFor(oSelection.line), left: oSelection.left + 'px', width: oSelection.width + 'px'}"
></span>
<span
v-for="(oLine, iIndex) in lines"
:key="iIndex"
class="page__line"
:class="isOpenLine(oLine) ? null : 'page__line--closed'"
>{{ lineText(oLine) }}</span>
<span
v-if="placeholderLine >= 0"
class="page__placeholder"
:style="{top: offsetFor(placeholderLine)}"
>{{ placeholder }}</span>
<span
v-if="caret"
class="page__caret"
:style="{top: offsetFor(caret.line), left: caret.x + 'px'}"
></span>
</div>
</div>
</div>
<div class="page__foot">
<span class="page__number">{{ pageNumber }}</span>
</div>
</section>
</template>
+126
View File
@@ -0,0 +1,126 @@
<script>
import appIcon from '@components/AppIcon';
import { formatShortDate, formatTime } from '@scripts/time';
//Bookmarks shown either side of the entry being read. A whole book's worth of
//tabs is a scrollbar, not a rail - a handful around where you are is something
//you can actually aim at, and turning pages walks the window along.
const RAIL_REACH = 3;
/**
* The tabs down the side of the book: one per entry, in writing order.
*
* Each is stamped with the date and time the entry was started, in the timezone
* it was written in - so a book kept across a move still reads as the local
* time of each moment. Clicking one turns the book to that page.
*/
export default {
components: {
appIcon
},
props: {
bookmarks: {type: Array, required: true},
//The entry the visible spread belongs to
currentId: {type: Number, default: 0},
//The entry still being written
openId: {type: Number, default: 0},
hasOlder: {type: Boolean, default: false},
loading: {type: Boolean, default: false}
},
emits: ['pick', 'load-older'],
inject: ['lang'],
computed: {
tabs() {
return this.bookmarks.map((oBookmark) => ({
id: oBookmark.id,
date: formatShortDate(oBookmark.time, oBookmark.timezone, this.lang.locale),
time: formatTime(oBookmark.time, oBookmark.timezone, this.lang.locale),
preview: oBookmark.preview || this.lang.get('book.empty_entry'),
open: (oBookmark.status === 'open')
}));
},
/**
* The three either side of where the reading is.
*
* Near the ends of the book there are simply fewer - the window is not
* padded back to a fixed size, because a tab that is not next to where
* you are is not what "next" means.
*/
shown() {
const iCurrent = this.tabs.findIndex((oTab) => oTab.id === this.currentId);
const iAnchor = (iCurrent >= 0) ? iCurrent : (this.tabs.length - 1);
return this.tabs.slice(
Math.max(0, iAnchor - RAIL_REACH),
Math.min(this.tabs.length, iAnchor + RAIL_REACH + 1)
);
},
//Whether the window itself has more book beyond it, in either direction
hasBefore() {
return (this.shown.length > 0) && (this.shown[0].id !== this.tabs[0]?.id);
},
hasAfter() {
return (this.shown.length > 0) && (this.shown.at(-1).id !== this.tabs.at(-1)?.id);
}
},
watch: {
currentId() {
this.$nextTick(() => this.scrollToCurrent());
}
},
mounted() {
//The rail is chronological, so the page being written is at the bottom.
this.$nextTick(() => this.scrollToCurrent());
},
methods: {
scrollToCurrent() {
const oList = this.$refs.list;
if(!oList) return;
const oTab = oList.querySelector('.rail__tab--current');
if(oTab) oTab.scrollIntoView({block: 'nearest'});
else oList.scrollTop = oList.scrollHeight;
}
}
};
</script>
<template>
<nav class="rail" :aria-label="lang.get('book.bookmarks')">
<span class="rail__title">{{ lang.get('book.bookmarks') }}</span>
<!-- There is more book above the window, or older entries still to load -->
<button
v-if="hasBefore || hasOlder"
type="button"
class="rail__more"
:disabled="loading"
:title="lang.get('action.prev_page')"
@click="$emit('load-older')"
>
<appIcon icon="chevronLeft" size="0.8em" />
</button>
<div ref="list" class="rail__list">
<button
v-for="oTab in shown"
:key="oTab.id"
type="button"
class="rail__tab"
:class="{
'rail__tab--current': (oTab.id === currentId),
'rail__tab--open': (oTab.id === openId)
}"
@click="$emit('pick', oTab.id)"
>
<span class="rail__date">
<span class="rail__day">{{ oTab.date }}</span>
<span class="rail__time">{{ oTab.time }}</span>
</span>
<span class="rail__preview">{{ oTab.preview }}</span>
</button>
</div>
<span v-if="tabs.length === 0" class="rail__empty">{{ lang.get('book.first_page') }}</span>
</nav>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script>
import appIcon from '@components/AppIcon';
import { formatMonthTitle, getLocalDayKey, getWeekdayInitials } from '@scripts/time';
/**
* A month at a time, with a mark under every day that was written on.
*
* The marks come from the bookmark list, which the journal already holds in
* full - so this needs no request of its own, and doubles as a picture of how
* regularly the book is being kept.
*/
export default {
components: {
appIcon
},
props: {
//Map of 'YYYY-MM-DD' to number of entries started that day
days: {type: Map, required: true},
//The day the book is currently open at
currentDay: {type: String, default: ''}
},
emits: ['pick'],
inject: ['lang'],
data() {
return {
//Opens on the month being read, or on this month
oMonth: this.getMonthStart(this.currentDay)
};
},
computed: {
monthTitle() {
return formatMonthTitle(this.oMonth, this.lang.locale);
},
weekdays() {
return getWeekdayInitials(this.lang.locale);
},
today() {
return getLocalDayKey(new Date());
},
//Six full weeks, always - a grid that changes height as you page
//through months is far more distracting than one blank row.
cells() {
const oFirst = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth(), 1);
//getDay() is Sunday-first; the grid is Monday-first
const iLead = (oFirst.getDay() + 6) % 7;
const asCells = [];
for(let iCell = 0; iCell < 42; iCell++) {
const oDate = new Date(oFirst.getFullYear(), oFirst.getMonth(), 1 - iLead + iCell);
const sKey = getLocalDayKey(oDate);
asCells.push({
key: sKey,
day: oDate.getDate(),
outside: (oDate.getMonth() !== this.oMonth.getMonth()),
future: (sKey > this.today),
count: this.days.get(sKey) || 0
});
}
return asCells;
},
monthCount() {
return this.cells.reduce((iTotal, oCell) => iTotal + (oCell.outside ? 0 : oCell.count), 0);
},
//Nothing was ever written in the future, so there is nowhere to go
atLastMonth() {
const oNow = new Date();
return (this.oMonth.getFullYear() === oNow.getFullYear()) && (this.oMonth.getMonth() === oNow.getMonth());
}
},
watch: {
currentDay(sDay) {
if(sDay !== '') this.oMonth = this.getMonthStart(sDay);
}
},
methods: {
getMonthStart(sDay) {
//'YYYY-MM-DD' split by hand: new Date('2026-03-01') is parsed as UTC
//and can land in the previous month west of Greenwich.
const asParts = (sDay || '').split('-');
if(asParts.length === 3) return new Date(Number(asParts[0]), Number(asParts[1]) - 1, 1);
const oNow = new Date();
return new Date(oNow.getFullYear(), oNow.getMonth(), 1);
},
step(iMonths) {
this.oMonth = new Date(this.oMonth.getFullYear(), this.oMonth.getMonth() + iMonths, 1);
},
goToToday() {
const oNow = new Date();
this.oMonth = new Date(oNow.getFullYear(), oNow.getMonth(), 1);
this.$emit('pick', this.today);
},
pick(oCell) {
this.$emit('pick', oCell.key);
}
}
};
</script>
<template>
<div class="calendar">
<div class="calendar__head">
<button
type="button"
class="calendar__step"
:title="lang.get('action.prev_page')"
@click="step(-1)"
>
<appIcon icon="chevronLeft" />
</button>
<span class="calendar__month">{{ monthTitle }}</span>
<button
type="button"
class="calendar__step"
:disabled="atLastMonth"
:title="lang.get('action.next_page')"
@click="step(1)"
>
<appIcon icon="chevronRight" />
</button>
</div>
<div class="calendar__grid">
<span v-for="sDay in weekdays" :key="sDay" class="calendar__weekday">{{ sDay }}</span>
<button
v-for="oCell in cells"
:key="oCell.key"
type="button"
class="calendar__day"
:class="{
'calendar__day--outside': oCell.outside,
'calendar__day--written': (oCell.count > 0),
'calendar__day--today': (oCell.key === today),
'calendar__day--current': (oCell.key === currentDay)
}"
:disabled="oCell.future"
@click="pick(oCell)"
>
{{ oCell.day }}
</button>
</div>
<div class="calendar__foot">
<button type="button" class="calendar__link" @click="goToToday">
{{ lang.get('book.today') }}
</button>
<span class="calendar__count">{{ monthCount }} {{ lang.get('book.entries').toLowerCase() }}</span>
</div>
</div>
</template>
+91
View File
@@ -0,0 +1,91 @@
<script>
import appIcon from '@components/AppIcon';
import { SAVE_FAILED, SAVE_IDLE, SAVE_PENDING, SAVE_SAVED, SAVE_SAVING } from '@scripts/journal';
import { formatSince } from '@scripts/time';
/**
* Ambient reassurance that the writing is landing somewhere.
*
* Deliberately quiet: autosave is the normal case, so the only state allowed to
* draw the eye is the one that needs a decision - a save that failed, which
* becomes a button that retries it.
*/
export default {
components: {
appIcon
},
inject: ['journal', 'lang'],
data() {
return {
//Bumped on an interval purely so the "saved 3 minutes ago" stamp
//keeps up without the journal having to emit anything.
iTick: 0
};
},
computed: {
state() {
return this.journal.saveState;
},
visible() {
return (this.state !== SAVE_IDLE) || (this.journal.savedAt > 0);
},
failed() {
return (this.state === SAVE_FAILED);
},
label() {
switch(this.state) {
case SAVE_SAVING:
return this.lang.get('save.saving');
case SAVE_PENDING:
return this.lang.get('save.pending');
case SAVE_FAILED:
return this.lang.get('save.failed');
default:
return (this.journal.savedAt > 0) ? this.lang.get('save.saved', this.since) : '';
}
},
since() {
//Reading iTick is what subscribes this to the interval.
return (this.iTick >= 0) ? formatSince(this.journal.savedAt, this.lang.locale, this.lang.get('save.just_now')) : '';
},
icon() {
if(this.failed) return 'alert';
return (this.state === SAVE_SAVED) ? 'check' : '';
},
classes() {
return {
'saver--saving': (this.state === SAVE_SAVING),
'saver--pending': (this.state === SAVE_PENDING),
'saver--failed': this.failed
};
}
},
mounted() {
this.oTicker = setInterval(() => this.iTick++, 30000);
},
beforeUnmount() {
clearInterval(this.oTicker);
},
methods: {
retry() {
if(this.failed) this.journal.save();
}
}
};
</script>
<template>
<component
:is="failed ? 'button' : 'span'"
v-if="visible"
class="saver"
:class="classes"
:type="failed ? 'button' : null"
:title="failed ? journal.error : null"
@click="retry"
>
<appIcon v-if="icon" :icon="icon" size="0.95em" />
<span v-else class="saver__dot"></span>
<span>{{ label }}</span>
</component>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script>
import appIcon from '@components/AppIcon';
/**
* Account settings, saved one field at a time.
*
* There is no Save button on purpose: each field commits on change, the same
* way the book itself autosaves. A language change is the one setting that
* needs the page back to pick up the new dictionary.
*/
export default {
components: {
appIcon
},
emits: ['close', 'signed-out'],
inject: ['api', 'consts', 'lang', 'user'],
data() {
return {
sName: this.user.name,
sError: '',
sSaved: '',
bBusy: false
};
},
computed: {
languages() {
return this.consts.languages || ['en'];
},
//Intl knows the zone list; older engines only know the one in use.
timezones() {
try {
return Intl.supportedValuesOf('timeZone');
}
catch{
return [this.user.timezone].filter((sZone) => sZone !== '');
}
},
languageNames() {
const oNames = new Intl.DisplayNames([this.lang.locale], {type: 'language'});
return this.languages.map((sCode) => ({
code: sCode,
label: oNames.of(sCode) || sCode
}));
}
},
methods: {
async set(sField, sValue) {
this.bBusy = true;
this.sError = '';
this.sSaved = '';
try {
const asData = await this.api.post('account', {field: sField, value: sValue});
Object.assign(this.user, asData.user);
this.sSaved = sField;
//The dictionary is baked into the page, so a new language means
//a new page - and nothing is lost, the book is already saved.
if(sField === 'language') window.location.reload();
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message || this.lang.get('error.unexpected');
this.sName = this.user.name;
}
finally {
this.bBusy = false;
}
},
saveName() {
const sName = this.sName.trim();
if((sName !== '') && (sName !== this.user.name)) this.set('name', sName);
},
async signOut() {
this.bBusy = true;
try {
await this.api.post('logout');
this.$emit('signed-out');
}
catch(oError) {
this.sError = oError.desc_lang_text || oError.message;
}
finally {
this.bBusy = false;
}
}
}
};
</script>
<template>
<div class="veil" @click.self="$emit('close')">
<div class="leaf leaf--wide" role="dialog" aria-modal="true">
<div class="leaf__head">
<h2 class="leaf__title">{{ lang.get('account.settings') }}</h2>
<p class="leaf__sub">{{ user.email }}</p>
</div>
<p v-if="sError" class="leaf__error" role="alert">{{ sError }}</p>
<div class="leaf__row">
<label class="paper-label" for="set-name">{{ lang.get('account.name') }}</label>
<input
id="set-name"
v-model="sName"
class="paper-field"
type="text"
maxlength="100"
:disabled="bBusy"
@change="saveName"
@keyup.enter="saveName"
/>
</div>
<div class="leaf__row">
<label class="paper-label" for="set-lang">{{ lang.get('account.language') }}</label>
<select
id="set-lang"
class="paper-field"
:value="user.language"
:disabled="bBusy"
@change="set('language', $event.target.value)"
>
<option v-for="oLanguage in languageNames" :key="oLanguage.code" :value="oLanguage.code">
{{ oLanguage.label }}
</option>
</select>
</div>
<div class="leaf__row">
<label class="paper-label" for="set-tz">{{ lang.get('account.timezone') }}</label>
<select
id="set-tz"
class="paper-field"
:value="user.timezone"
:disabled="bBusy"
@change="set('timezone', $event.target.value)"
>
<option v-for="sZone in timezones" :key="sZone" :value="sZone">{{ sZone }}</option>
</select>
</div>
<div class="leaf__actions">
<button type="button" class="ink-button" @click="$emit('close')">
<appIcon icon="check" />
<span>{{ lang.get('action.close') }}</span>
</button>
<button type="button" class="text-button text-button--bad" :disabled="bBusy" @click="signOut">
<span>{{ lang.get('account.sign_out') }}</span>
</button>
</div>
</div>
</div>
</template>
+23
View File
@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180" role="img" aria-label="MyThoughts">
<title>MyThoughts</title>
<defs>
<linearGradient id="desk" x1="0" y1="0" x2="0.4" y2="1">
<stop offset="0" stop-color="#44321e"/>
<stop offset="0.55" stop-color="#2c2114"/>
<stop offset="1" stop-color="#1a140d"/>
</linearGradient>
</defs>
<!-- Touch icons are masked by the OS, so the artwork keeps clear of the edges -->
<rect width="180" height="180" fill="url(#desk)"/>
<g transform="translate(90 92) scale(2.55) translate(-32 -33)">
<path d="M32 19.5C27.7 15.9 22.2 14.2 14.5 14.2v32.4c7.7 0 13.2 1.7 17.5 5.2 4.3-3.5 9.8-5.2 17.5-5.2V14.2c-7.7 0-13.2 1.7-17.5 5.3z"
fill="#f8f2e4" stroke="#cdbb99" stroke-width="1.6" stroke-linejoin="round"/>
<path d="M32 19.5v32.3" stroke="#cdbb99" stroke-width="2" stroke-linecap="round"/>
<g stroke="#6d6358" stroke-width="1.7" stroke-linecap="round" opacity="0.75">
<path d="M20.5 26.5h7.5"/>
<path d="M20.5 32h7.5"/>
<path d="M20.5 37.5h5"/>
</g>
<path d="M36.5 14.6h6v16l-3-2.4-3 2.4z" fill="#795731"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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

After

Width:  |  Height:  |  Size: 903 B

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

After

Width:  |  Height:  |  Size: 55 KiB

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