mirror of
https://gitlab.com/octospacc/WhichNot.git
synced 2025-06-27 09:02:56 +02:00
UI improvements
This commit is contained in:
7
README.md
Normal file
7
README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# WhichNot
|
||||||
|
|
||||||
|
WhichNot is an experimental offline-first note-taking application, aimed at recreating the look-and-feel and ease of use of a standard messaging app.
|
||||||
|
|
||||||
|
**Try and use it now at <https://whichnot.octt.eu.org/>!** (Includes a demo notebook with more info about the app.)
|
||||||
|
|
||||||
|

|
33
app.js
33
app.js
@ -113,7 +113,7 @@ const uuidv7 = () => {
|
|||||||
}
|
}
|
||||||
const generateUUID = () => uuidv7(); // crypto.randomUUID();
|
const generateUUID = () => uuidv7(); // crypto.randomUUID();
|
||||||
const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||||
// const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign', 'verify']);
|
const genEcdsaP256 = async () => crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
|
||||||
const exportJWK = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key)));
|
const exportJWK = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key)));
|
||||||
const importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
|
const importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
|
||||||
const randBytes = (n=12) => {
|
const randBytes = (n=12) => {
|
||||||
@ -175,6 +175,14 @@ const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
|
|||||||
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
|
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
|
||||||
|
|
||||||
const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } });
|
const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } });
|
||||||
|
const makeTextareaHeight = text => {
|
||||||
|
let lines = text.split('\n').length;
|
||||||
|
if (lines > 10) {
|
||||||
|
lines = 10;
|
||||||
|
}
|
||||||
|
return `${lines + 2}em`;
|
||||||
|
};
|
||||||
|
const textareaInputHandler = el => (el.style.minHeight = makeTextareaHeight(el.value));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
@ -186,6 +194,7 @@ function App() {
|
|||||||
contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
|
contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
|
||||||
searchModal: { visible: false, global: false, query: '' },
|
searchModal: { visible: false, global: false, query: '' },
|
||||||
editingMessage: null, replyingTo: null, reactionInputFor: null,
|
editingMessage: null, replyingTo: null, reactionInputFor: null,
|
||||||
|
debugMode: false,
|
||||||
});
|
});
|
||||||
const isFirstHashPush = useRef(true);
|
const isFirstHashPush = useRef(true);
|
||||||
const messageInputRef = useRef();
|
const messageInputRef = useRef();
|
||||||
@ -283,13 +292,14 @@ function App() {
|
|||||||
let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */
|
let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */
|
||||||
// if (!id) return;
|
// if (!id) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
// const ed = await genEd25519();
|
// const ecdsa = await genEcdsaP256();
|
||||||
const notebook = {
|
const notebook = {
|
||||||
id, name: `${STRINGS.get('Notebook')} ${now}`, description: '',
|
id, name: `${STRINGS.get('Notebook')} ${now}`, description: '',
|
||||||
emoji: randomEmoji(), color: randomColor(),
|
emoji: randomEmoji(), color: randomColor(),
|
||||||
parseMode: "markdown", // sourceType: type,
|
parseMode: "markdown", // sourceType: type,
|
||||||
nextMessageId: 1, created: now,
|
nextMessageId: 1, created: now,
|
||||||
aesKeyB64: await exportJWK(await genAESKey()), // edPrivB64: await exportJWK(ed.privateKey), edPubB64: await exportJWK(ed.publicKey),
|
aesKeyB64: await exportJWK(await genAESKey()),
|
||||||
|
// ecdsaPrivB64: await exportJWK(ecdsa.privateKey), ecdsaPubB64: await exportJWK(ecdsa.publicKey),
|
||||||
};
|
};
|
||||||
setState(s => ({ ...s,
|
setState(s => ({ ...s,
|
||||||
notebooks: [ ...s.notebooks, notebook ],
|
notebooks: [ ...s.notebooks, notebook ],
|
||||||
@ -380,6 +390,7 @@ function App() {
|
|||||||
const message = state.messages[state.selectedNotebookId]?.[state.editingMessage];
|
const message = state.messages[state.selectedNotebookId]?.[state.editingMessage];
|
||||||
if (message) {
|
if (message) {
|
||||||
messageInputRef.current.value = message.text;
|
messageInputRef.current.value = message.text;
|
||||||
|
textareaInputHandler(messageInputRef.current);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.editingMessage, state.selectedNotebookId, state.messages]);
|
}, [state.editingMessage, state.selectedNotebookId, state.messages]);
|
||||||
@ -404,6 +415,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
message = { ...message, text, edited: (state.editingMessage!=null ? (text !== message.text ? Date.now() : message.edited) : false), };
|
message = { ...message, text, edited: (state.editingMessage!=null ? (text !== message.text ? Date.now() : message.edited) : false), };
|
||||||
messageInputRef.current.value = '';
|
messageInputRef.current.value = '';
|
||||||
|
messageInputRef.current.style.minHeight = null;
|
||||||
// update nextMessageId if new
|
// update nextMessageId if new
|
||||||
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===notebookId
|
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===notebookId
|
||||||
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
|
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
|
||||||
@ -501,7 +513,7 @@ function ChatScreen({messageInputRef}) {
|
|||||||
${!notebook.readonly && html`<div class="SendBar">
|
${!notebook.readonly && html`<div class="SendBar">
|
||||||
${state.replyingTo && html`
|
${state.replyingTo && html`
|
||||||
<div class="ReplyPreview">
|
<div class="ReplyPreview">
|
||||||
<span>${STRINGS.get('Reply to')}: "${
|
<span class="ReplyPreviewText">${STRINGS.get('Reply to')}: "${
|
||||||
getMessage(state.replyingTo.notebookId, state.replyingTo.messageId)?.text || ''
|
getMessage(state.replyingTo.notebookId, state.replyingTo.messageId)?.text || ''
|
||||||
}"</span>
|
}"</span>
|
||||||
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
|
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
|
||||||
@ -514,7 +526,7 @@ function ChatScreen({messageInputRef}) {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
sendMessage();
|
sendMessage();
|
||||||
}
|
}
|
||||||
}}/>
|
}} onInput=${ev => textareaInputHandler(ev.target)} />
|
||||||
<button onClick=${sendMessage}>${state.editingMessage!=null ? STRINGS.get('Save') : STRINGS.get('Send')}</button>
|
<button onClick=${sendMessage}>${state.editingMessage!=null ? STRINGS.get('Save') : STRINGS.get('Send')}</button>
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>
|
</div>
|
||||||
@ -557,7 +569,12 @@ function Message({message, notebook}) {
|
|||||||
<button onClick=${() => removeReaction(message.id, reaction)} disabled=${notebook.readonly}>${reaction}</button>
|
<button onClick=${() => removeReaction(message.id, reaction)} disabled=${notebook.readonly}>${reaction}</button>
|
||||||
`)}
|
`)}
|
||||||
${!notebook.readonly && (state.reactionInputFor===message.id
|
${!notebook.readonly && (state.reactionInputFor===message.id
|
||||||
? html`<input class="ReactionInput" maxlength="2" autofocus onKeyDown=${e => e.key==='Enter' && (confirmReaction(message.id, e.target.value), e.target.value='')} />`
|
? html`<input type="text" class="ReactionInput" maxlength="2" autofocus enterkeyhint="done" onKeyDown=${ev => {
|
||||||
|
if (ev.key==='Enter') {
|
||||||
|
confirmReaction(message.id, ev.target.value);
|
||||||
|
ev.target.value = '';
|
||||||
|
}
|
||||||
|
}} />`
|
||||||
: html`<button class="AddReactionBtn" onClick=${() => addReaction(message.id)}>➕</button>`
|
: html`<button class="AddReactionBtn" onClick=${() => addReaction(message.id)}>➕</button>`
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -682,7 +699,7 @@ function NotebookSettingsModal() {
|
|||||||
<p><label>${STRINGS.get('Name')}: <input value=${form.name} onChange=${ev => setForm(f => ({ ...f, name: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
<p><label>${STRINGS.get('Name')}: <input value=${form.name} onChange=${ev => setForm(f => ({ ...f, name: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
||||||
<p><label>Emoji: <input value=${form.emoji} maxLength="2" onChange=${ev => setForm(f => ({ ...f, emoji: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
<p><label>Emoji: <input value=${form.emoji} maxLength="2" onChange=${ev => setForm(f => ({ ...f, emoji: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
||||||
<p><label>${STRINGS.get('Color')}: <input type="color" value=${form.color || 'transparent'} onChange=${ev => setForm(f => ({ ...f, color: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
<p><label>${STRINGS.get('Color')}: <input type="color" value=${form.color || 'transparent'} onChange=${ev => setForm(f => ({ ...f, color: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
||||||
<p><label>${STRINGS.get('Description')}: <textarea onChange=${ev => setForm(f => ({ ...f, description: ev.target.value }))} disabled=${notebook.readonly}>${form.description}</textarea></label></p>
|
<p><label>${STRINGS.get('Description')}: <textarea style=${{ minHeight: makeTextareaHeight(form.description) }} onChange=${ev => setForm(f => ({ ...f, description: ev.target.value }))} onInput=${ev => textareaInputHandler(ev.target)} disabled=${notebook.readonly}>${form.description}</textarea></label></p>
|
||||||
<p><label>Parse Mode: <select value=${form.parseMode || UNSPECIFIEDS.parseMode} onChange=${ev => setForm(f => ({ ...f, parseMode: ev.target.value }))} disabled=${notebook.readonly}>
|
<p><label>Parse Mode: <select value=${form.parseMode || UNSPECIFIEDS.parseMode} onChange=${ev => setForm(f => ({ ...f, parseMode: ev.target.value }))} disabled=${notebook.readonly}>
|
||||||
<option value="plaintext">Plaintext</option>
|
<option value="plaintext">Plaintext</option>
|
||||||
<option value="markdown">Markdown</option>
|
<option value="markdown">Markdown</option>
|
||||||
@ -752,6 +769,8 @@ function AppSettingsModal() {
|
|||||||
<h4>${STRINGS.get('Import Data')}</h4>
|
<h4>${STRINGS.get('Import Data')}</h4>
|
||||||
<textarea rows="8" placeholder=${STRINGS.get('Paste JSON')} onInput=${ev => setImportTxt(ev.target.value)} />
|
<textarea rows="8" placeholder=${STRINGS.get('Paste JSON')} onInput=${ev => setImportTxt(ev.target.value)} />
|
||||||
<button onClick=${doImport}>${STRINGS.get('Import Data')}</button>
|
<button onClick=${doImport}>${STRINGS.get('Import Data')}</button>
|
||||||
|
<!--<h4>Other</h4>
|
||||||
|
<label><input type="checkbox" checked=${state.debugMode} onChange=${ev => setState(s => ({ ...s, debugMode: ev.target.checked }))} /> Experimental/Debug features ${state.debugMode && html`(resets on restart)`}</label>-->
|
||||||
<br /><br />
|
<br /><br />
|
||||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings: false }))}>${STRINGS.get('Close')}</button>
|
<button onClick=${() => setState(s => ({ ...s, showAppSettings: false }))}>${STRINGS.get('Close')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -43,7 +43,7 @@
|
|||||||
.NotebookName { margin: 0; font-size: 1rem; }
|
.NotebookName { margin: 0; font-size: 1rem; }
|
||||||
.NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; }
|
.NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; }
|
||||||
.NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; }
|
.NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; }
|
||||||
.NotebookDescription, .NotebookPreview { text-overflow: ellipsis; overflow: hidden; text-wrap: nowrap; }
|
.NotebookDescription, .NotebookPreview, .ReplyPreviewText { text-overflow: ellipsis; overflow: hidden; text-wrap: nowrap; }
|
||||||
|
|
||||||
.ChatScreen { flex: 1; display: none; flex-direction: column; background: var(--chat-bg); }
|
.ChatScreen { flex: 1; display: none; flex-direction: column; background: var(--chat-bg); }
|
||||||
.App.show-chat .ChatScreen { display: flex; }
|
.App.show-chat .ChatScreen { display: flex; }
|
||||||
@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
.SendBar { display: flex; gap: .5rem; padding: 1rem; background: var(--main-bg); border-top: 1px solid #ddd; flex-direction: column; }
|
.SendBar { display: flex; gap: .5rem; padding: 1rem; background: var(--main-bg); border-top: 1px solid #ddd; flex-direction: column; }
|
||||||
.ReplyPreview { background: var(--chat-bg); padding: .5rem; border-radius: .25rem; display: flex; justify-content: space-between; align-items: center; }
|
.ReplyPreview { background: var(--chat-bg); padding: .5rem; border-radius: .25rem; display: flex; justify-content: space-between; align-items: center; }
|
||||||
.EditArea { flex: 1; padding: .5rem; border: 1px solid #ddd; border-radius: .5rem; resize: none; }
|
.EditArea { /* flex: 1; */ padding: .5rem; border: 1px solid #ddd; border-radius: .5rem; resize: none; height: 4em; }
|
||||||
|
|
||||||
.ContextMenu {
|
.ContextMenu {
|
||||||
position: fixed; z-index: 1000; min-width: 140px;
|
position: fixed; z-index: 1000; min-width: 140px;
|
||||||
|
Reference in New Issue
Block a user