diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..fd6d1ed
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+localforage.min.js
+marked.min.js
+preact/
\ No newline at end of file
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..07363b0
--- /dev/null
+++ b/app.js
@@ -0,0 +1,693 @@
+window.appMain = () => {
+
+const html = htm.bind(h);
+const AppContext = createContext();
+
+localforage.config({ name: "WhichNot" });
+navigator.storage.persist();
+
+// marked.use({ renderer: {
+// image({ href, title, text }) { // allow embedding any media with ![]()
+// title = (title ? ` title="${escapeHtml(title)}"` : '');
+// return ``;
+// }
+// } });
+
+const STRINGS = {
+ "Notebook": { it: "Quaderno" },
+ "Copy": { it: "Copia" },
+ "Reply": { it: "Rispondi" },
+ "Reply in Another Notebook": { it: "Rispondi in un Altro Quaderno" },
+ "Reply to": { it: "Risposta a" },
+ "Edit": { it: "Modifica" },
+ "Edited": { it: "Modificato" },
+ "Set Date/Time": { it: "Imposta Data/Ora" },
+ "Send": { it: "Invia" },
+ "Save": { it: "Salva" },
+ "Delete": { it: "Elimina" },
+ "Cancel": { it: "Annulla" },
+ "Close": { it: "Chiudi" },
+ "Name": { it: "Nome" },
+ "Color": { it: "Colore" },
+ "Description": { it: "Descrizione" },
+ "Info/Settings": { it: "Info/Impostazioni" },
+ "App Settings": { it: "Impostazioni App" },
+ "Export Data": { it: "Esporta Dati" },
+ "Import Data": { it: "Importa Dati" },
+ "Paste JSON": { it: "Incolla JSON" },
+ "Invalid data format": { it: "Formato dati invalido" },
+ "Invalid JSON syntax": { it: "Sintassi JSON invalida" },
+ "No description": { it: "Nessuna descrizione" },
+ "No notes": { it: "Nessuna nota" },
+ "Info and Demo": { it: "Info e Demo" },
+};
+STRINGS.get = (name, lang=navigator.language.split('-')[0]) => (STRINGS[name]?.[lang] || STRINGS[name]?.en || name);
+
+const UNSPECIFIEDS = {
+ parseMode: "plaintext",
+};
+const NOTEBOOKS = {
+ "WhichNot": {
+ emoji: "âšī¸",
+ description: STRINGS.get('Info and Demo'),
+ parseMode: "markdown",
+ readonly: true,
+ messages: [
+ {
+ text: "**WhichNot is finally released and REAL!!!** BILLIONS MUST ENJOY!!!",
+ created: "2025-04-20T23:00",
+ reactions: { "đ": true },
+ },
+ ],
+ },
+};
+Object.entries(NOTEBOOKS).forEach(([name, values]) => (NOTEBOOKS[name] = { id: name, name, ...values, messages: values.messages.map((message, id) => ({ id, ...message })) }));
+
+const uuidv7 = () => {
+ const bytes = new Uint8Array(16);
+ crypto.getRandomValues(bytes);
+ const time = BigInt(Date.now());
+ bytes[0] = Number((time >> 40n) & 0xffn);
+ bytes[1] = Number((time >> 32n) & 0xffn);
+ bytes[2] = Number((time >> 24n) & 0xffn);
+ bytes[3] = Number((time >> 16n) & 0xffn);
+ bytes[4] = Number((time >> 8n) & 0xffn);
+ bytes[5] = Number(time & 0xffn);
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const chars = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0'));
+ [10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-'));
+ return chars.join('');
+}
+const generateUUID = () => uuidv7(); // crypto.randomUUID();
+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 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 randBytes = (n=12) => {
+ const b = new Uint8Array(n);
+ crypto.getRandomValues(b);
+ return b;
+}
+const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
+const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0)));
+const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
+ { name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' },
+ await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
+ { name: 'AES-GCM', length: 256 },
+ true, ['encrypt', 'decrypt']);
+const getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJWK(aesKeyB64, { name: 'AES-GCM' }, ['encrypt','decrypt']));
+
+const encryptMessage = async (message, rawKey) => {
+ const salt = randBytes();
+ const iv = randBytes(12);
+ const key = await deriveMsgKey(rawKey, salt);
+ const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(message.text));
+ const encrypted = { ...message,
+ salt: bufToB64(salt),
+ iv: bufToB64(iv),
+ ciphertext: bufToB64(ct),
+ };
+ delete encrypted.text;
+ return encrypted;
+};
+const decryptMessage = async (encrypted, rawKey) => {
+ const salt = b64ToBuf(encrypted.salt);
+ const iv = b64ToBuf(encrypted.iv);
+ const key = await deriveMsgKey(rawKey, salt);
+ const ct = b64ToBuf(encrypted.ciphertext);
+ const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
+ return { ...encrypted, text: new TextDecoder().decode(dec) };
+};
+
+const escapeHtml = text => {
+ const node = document.createElement('p');
+ node.appendChild(document.createTextNode(text));
+ return node.innerHTML;
+}
+const makeParagraph = text => `
${text.replaceAll('\n', '
')}
`
+const linkify = text => text.replace(/(\bhttps?:\/\/[^\s]+)/g, '$1');
+const getFirstLink = html => Object.assign(document.createElement('div'), { innerHTML: html }).querySelector('a[href]')?.getAttribute('href');
+const renderTextMessage = (text, notebook) => {
+ const parseMode = notebook.parseMode || UNSPECIFIEDS.parseMode;
+ switch (parseMode) {
+ case 'plaintext':
+ return makeParagraph(linkify(escapeHtml(text)));
+ case 'markdown':
+ return marked.parse(escapeHtml(text));
+ }
+};
+
+const EMOJIS = ['đ','đ','đ','đ','đ','đ','đ','đ','đ','âī¸','đ'];
+const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
+const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
+
+const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } });
+
+function App() {
+ const [state, setState] = useState({
+ notebooks: [], encrypteds: {}, messages: {},
+ selectedNotebookId: null, scrollToMessageId: null,
+ showNotebookSettings: false, showAppSettings: false,
+ createModal: false, dateTimeModal: null,
+ crossReplyModal: false, crossReplySource: null,
+ contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
+ searchModal: { visible: false, global: false, query: '' },
+ editingMessage: null, replyingTo: null, reactionInputFor: null,
+ });
+ const messageInputRef = useRef();
+ const [loading, setLoading] = useState(true);
+
+ // Load data from storage
+ useEffect(() => {
+ (async () => {
+ const notebooksList = await localforage.getItem('notebooks') || [];
+ const notebooks = [];
+ const [messagesStore, encryptedsStore] = [{}, {}];
+ await Promise.all(notebooksList.map(async notebook => {
+ notebooks.push(notebook = await localforage.getItem(`notebooks/${notebook}`));
+ const [messages, encrypteds] = [{}, {}];
+ const messagesList = await localforage.getItem(`messages/${notebook.id}`);
+ const rawKey = await getAesRawKey(notebook.aesKeyB64);
+ await Promise.all(messagesList.map(async messageId => (encrypteds[messageId] = await localforage.getItem(`messages/${notebook.id}/${messageId}`))));
+ await Promise.all(Object.values(encrypteds).map(async encrypted => (messages[encrypted.id] = await decryptMessage(encrypted, rawKey))));
+ encryptedsStore[notebook.id] = encrypteds;
+ messagesStore[notebook.id] = messages;
+ }));
+ setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore }));
+ setLoading(false);
+ })();
+ }, []);
+
+ // Persist notebooks meta
+ useEffect(() => {
+ if (!loading) {
+ for (const notebook of state.notebooks) {
+ localforage.setItem(`notebooks/${notebook.id}`, notebook);
+ }
+ localforage.setItem('notebooks', state.notebooks.map(notebook => notebook.id));
+ }
+ }, [state.notebooks]);
+
+ // Persist encrypted store
+ useEffect(() => {
+ if (!loading) {
+ for (const notebookId in state.encrypteds) {
+ const messages = state.encrypteds[notebookId];
+ if (!messages) return;
+ localforage.setItem(`messages/${notebookId}`, Object.keys(messages));
+ for (const message of Object.values(messages)) {
+ localforage.setItem(`messages/${notebookId}/${message.id}`, message);
+ }
+ }
+ }
+ }, [state.encrypteds]);
+
+ // Close context on click-away
+ useEffect(() => {
+ const handler = ev => {
+ if (state.contextMenu.visible) {
+ const menu = document.querySelector('.ContextMenu');
+ if (menu && !menu.contains(ev.target)) {
+ setState(s => ({ ...s, ...closedContextMenu(s) }));
+ }
+ }
+ };
+ document.addEventListener('click', handler);
+ return () => document.removeEventListener('click', handler);
+ }, [state.contextMenu.visible]);
+
+ const createNotebook = useCallback(async (type) => {
+ let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */
+ // if (!id) return;
+ const now = Date.now();
+ // const ed = await genEd25519();
+ const notebook = {
+ id, name: `${STRINGS.get('Notebook')} ${now}`, description: '',
+ emoji: randomEmoji(), color: randomColor(),
+ parseMode: "markdown", // sourceType: type,
+ nextMessageId: 1, created: now,
+ aesKeyB64: await exportJWK(await genAESKey()), // edPrivB64: await exportJWK(ed.privateKey), edPubB64: await exportJWK(ed.publicKey),
+ };
+ setState(s => ({ ...s,
+ notebooks: [ ...s.notebooks, notebook ],
+ encrypteds: { ...s.encrypteds, [id]: {} },
+ messages: { ...s.messages, [id]: {} },
+ createModal: false,
+ }));
+ // if (type==='remote') {
+ // await fetch(`/notebook/${id}`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' },
+ // body: JSON.stringify({ publicKey: pubB64 }),
+ // });
+ // }
+ }, [state.notebooks]);
+
+ const getNotebook = useCallback(notebookId => (state.notebooks.find(notebook => (notebook.id === notebookId)) || NOTEBOOKS[notebookId]), [state.notebooks]);
+ const deleteNotebook = (notebookId) => {
+ const messagesList = Object.keys(getMessages(notebookId));
+ setState(s => ({ ...s,
+ notebooks: s.notebooks.filter(notebook => (notebook.id !== notebookId)),
+ messages: { ...s.messages, [notebookId]: undefined },
+ encrypteds: { ...s.encrypteds, [notebookId]: undefined },
+ }));
+ localforage.removeItem(`notebooks/${notebookId}`);
+ localforage.removeItem(`messages/${notebookId}`);
+ for (const messageId of messagesList) {
+ localforage.removeItem(`messages/${notebookId}/${messageId}`);
+ }
+ };
+
+ const getMessages = useCallback((notebookId) => (state.messages[notebookId] || NOTEBOOKS[notebookId]?.messages || {}), [state.messages]);
+ const getMessage = useCallback((notebookId, messageId) => getMessages(notebookId)[messageId], [state.messages]);
+
+ const saveMessage = (notebookId, message) => persistMessages(notebookId, { ...getMessages(notebookId), [message.id]: message });
+ const deleteMessage = (notebookId, messageId) => {
+ const messages = getMessages(notebookId);
+ delete messages[messageId];
+ persistMessages(notebookId, messages);
+ localforage.removeItem(`messages/${notebookId}/${messageId}`);
+ };
+ const copyMessage = message => navigator.clipboard.writeText(message.text);
+
+ const persistMessages = useCallback(async (notebookId, messages) => {
+ const notebook = getNotebook(notebookId);
+ // if (!notebook) return;
+ const rawKey = await getAesRawKey(notebook.aesKeyB64);
+ const encrypteds = {};
+ await Promise.all(Object.values(messages).map(async message => (encrypteds[message.id] = await encryptMessage(message, rawKey))));
+ setState(s => ({ ...s,
+ encrypteds: { ...s.encrypteds, [notebookId]: encrypteds },
+ messages: { ...s.messages, [notebookId]: messages },
+ }));
+ // if (notebook.sourceType==='remote') {
+ // const priv = await importJWK(notebook.edPrivB64, { name: 'Ed25519', namedCurve: 'Ed25519' }, ['sign']),
+ // payload = new TextEncoder().encode(JSON.stringify(encArr)),
+ // sig = bufToB64(await crypto.subtle.sign('Ed25519', priv, payload));
+ // await fetch(`/notebook/${nbId}/sync`, {
+ // method: 'PUT', headers: { 'Content-Type': 'application/json' },
+ // body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }),
+ // });
+ // }
+ }, [state.notebooks]);
+
+ const addReaction = useCallback(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []);
+ const confirmReaction = useCallback(async (messageId, emoji) => {
+ setState(s => ({ ...s, reactionInputFor: null }));
+ if (!emoji) return;
+ const notebookId = state.selectedNotebookId;
+ const message = getMessage(notebookId, messageId);
+ if (!(emoji in message.reactions)) {
+ message.reactions[emoji] = true;
+ saveMessage(notebookId, message);
+ }
+ }, [state.selectedNotebookId, state.messages, persistMessages]);
+ const removeReaction = useCallback(async (messageId, emoji) => {
+ const notebookId = state.selectedNotebookId;
+ const message = getMessage(notebookId, messageId);
+ if (emoji in message.reactions) {
+ delete message.reactions[emoji];
+ saveMessage(notebookId, message);
+ }
+ }, [state.selectedNotebookId, state.messages, persistMessages]);
+ useEffect(() => setState(s => ({ ...s, reactionInputFor: null })), [state.selectedNotebookId]);
+
+ // Editing effect: prefill textarea when entering edit mode
+ useEffect(() => {
+ if (state.editingMessage!=null && messageInputRef.current) {
+ const message = state.messages[state.selectedNotebookId]?.[state.editingMessage];
+ if (message) {
+ messageInputRef.current.value = message.text;
+ }
+ }
+ }, [state.editingMessage, state.selectedNotebookId, state.messages]);
+
+ useEffect(() => (state.scrollToMessageId==null && Array.from(document.querySelectorAll('.Message[data-message-id]')).slice(-1)[0]?.scrollIntoView({ behavior: 'smooth', block: 'start' })), [state.selectedNotebookId]);
+
+ const sendMessage = useCallback(async () => {
+ const notebookId = state.selectedNotebookId;
+ //if (!notebookId) return;
+ const text = messageInputRef.current.value.trim();
+ if (!text) return;
+ const notebook = getNotebook(notebookId);
+ let message = getMessage(notebookId, state.editingMessage);
+ if (!message) {
+ message = {
+ id: notebook.nextMessageId,
+ created: Date.now(),
+ replyTo: state.replyingTo,
+ reactions: {},
+ };
+ }
+ message = { ...message, text, edited: (state.editingMessage!=null ? Date.now() : false), };
+ messageInputRef.current.value = '';
+ // update nextMessageId if new
+ setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===notebookId
+ ? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
+ : notebook
+ ) }));
+ saveMessage(notebookId, message);
+ setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
+ }, [state.selectedNotebookId, state.editingMessage, state.replyingTo, state.messages, state.notebooks]);
+
+ return html`
+ <${AppContext.Provider} value=${{
+ state, setState, createNotebook,
+ getNotebook, deleteNotebook,
+ getMessages, getMessage,
+ sendMessage, persistMessages,
+ saveMessage, deleteMessage, copyMessage,
+ addReaction, confirmReaction, removeReaction,
+ }}>
+
+ <${ChatList} />
+ <${ChatScreen} messageInputRef=${messageInputRef} />
+ ${state.createModal && html`<${CreateModal} />`}
+ ${state.crossReplyModal && html`<${CrossReplyModal} />`}
+ ${state.showNotebookSettings && html`<${NotebookSettingsModal} />`}
+ ${state.showAppSettings && html`<${AppSettingsModal} />`}
+ ${state.contextMenu.visible && html`<${ContextMenu} />`}
+ ${state.dateTimeModal!==null && html`<${DateTimeModal} />`}
+ ${state.searchModal.visible && html`<${SearchModal} />`}
+
+ />
+ `;
+}
+
+function ChatList() {
+ const {state, setState, getMessages} = useContext(AppContext);
+ const sortNotebook = (notebook) => Math.max(notebook.created, ...Object.values(getMessages(notebook.id) || []).map(message => message.created));
+ return html`
+
+
+ ${[ ...state.notebooks.sort((a, b) => (sortNotebook(b) - sortNotebook(a))), ...Object.values(NOTEBOOKS) ].map(notebook => html`
+
+ `)}
+
+ `;
+}
+
+function ChatScreen({messageInputRef}) {
+ const {state, setState, sendMessage, getMessage, getMessages, getNotebook} = useContext(AppContext);
+ const notebook = getNotebook(state.selectedNotebookId);
+ if (!notebook) return null;
+ const messages = Object.values(getMessages(notebook.id)).sort((a, b) => (a.created - b.created));
+ // Scroll on request
+ useEffect(() => {
+ if (state.scrollToMessageId != null) {
+ document.querySelector(`.Message[data-message-id="${state.scrollToMessageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ setState(s => ({ ...s, scrollToMessageId: null }));
+ }
+ }, [state.scrollToMessageId, state.selectedNotebookId]);
+ return html`
+
+
+
+ ${messages.map(message => html`<${Message} message=${message} notebook=${notebook} />`)}
+
+ ${!notebook.readonly && html`
+ ${state.replyingTo && html`
+
+ ${STRINGS.get('Reply to')}: "${
+ getMessage(state.replyingTo.notebookId, state.replyingTo.messageId)?.text || ''
+ }"
+
+
`}
+
`}
+
+ `;
+}
+
+function Message({message, notebook}) {
+ const {
+ state, setState, getMessage, getNotebook,
+ addReaction, confirmReaction, removeReaction
+ } = useContext(AppContext);
+ const rendered = renderTextMessage(message.text, notebook);
+ return html`
+ {
+ ev.preventDefault();
+ setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: ev.clientX, y: ev.clientY } }));
+ }}>
+ ${message.replyTo && html`
+
setState(s => ({ ...state,
+ selectedNotebookId: message.replyTo.notebookId,
+ scrollToMessageId: (message.replyTo.messageId || message.replyTo.id),
+ }))}>
+ ${STRINGS.get('Reply to')}: "${
+ getMessage(message.replyTo.notebookId, (message.replyTo.messageId || message.replyTo.id))?.text || ''
+ }"
+
`}
+
+ ${(() => {
+ const url = getFirstLink(rendered);
+ if (url) {
+ return html`
+
+
`;
+ }
+ })()}
+
+ ${Object.keys(message.reactions || {}).map(reaction => html`
+
+ `)}
+ ${!notebook.readonly && (state.reactionInputFor===message.id
+ ? html` e.key==='Enter' && (confirmReaction(message.id, e.target.value), e.target.value='')} />`
+ : html``
+ )}
+
+
${new Date(message.created).toLocaleString()}${message.edited ? ` (${STRINGS.get('Edited').toLowerCase()})` : ''}
+
+ `
+}
+
+function CreateModal() {
+ const {createNotebook, setState} = useContext(AppContext);
+ createNotebook('local');
+ return '';
+ // return html`
+ //
+ //
Create Notebook
+ //
+ //
+ //
+ //
+ // `;
+}
+
+function CrossReplyModal() {
+ const {state, setState} = useContext(AppContext);
+ return html`
+
+
${STRINGS.get('Reply in Another Notebook')}
+ ${state.notebooks.filter(notebook => notebook.id!==state.crossReplySource.notebookId).map(notebook => html`
+
+ `)}
+
+
+ `;
+}
+
+function ContextMenu() {
+ const {state, setState, getNotebook, getMessage, copyMessage, deleteMessage} = useContext(AppContext);
+ const messageId = state.contextMenu.messageId;
+ const notebook = getNotebook(state.selectedNotebookId);
+ const message = getMessage(notebook.id, messageId);
+ const setFinalState = state => setState(s => ({ ...s, ...state, ...closedContextMenu(s) }));
+ const handle = action => {
+ switch (action) {
+ case 'reply':
+ return setFinalState({ replyingTo: { notebookId: notebook.id, messageId: message.id } });
+ case 'cross-reply':
+ return setFinalState({ crossReplyModal: true, crossReplySource: { notebookId: notebook.id, messageId: message.id } });
+ case 'copy':
+ copyMessage(message);
+ return setFinalState();
+ case 'edit':
+ return setFinalState({ editingMessage: messageId });
+ case 'datetime':
+ return setFinalState({ dateTimeModal: messageId });
+ case 'delete':
+ deleteMessage(notebook.id, messageId);
+ return setFinalState();
+ }
+ };
+ return html`
+
+ `;
+}
+
+function DateTimeModal() {
+ const {state, setState, getMessage, saveMessage} = useContext(AppContext);
+ const messageId = state.dateTimeModal;
+ const notebookId = state.selectedNotebookId;
+ const message = getMessage(notebookId, messageId);
+ const [dt, setDt] = useState('');
+ useEffect(() => (message && setDt(new Date(message.created).toISOString().slice(0, 16))), [message]);
+ const save = () => {
+ const timestamp = new Date(dt).getTime();
+ if (!isNaN(timestamp)) {
+ saveMessage(notebookId, { ...message, created: timestamp });
+ setState(s => ({ ...s, dateTimeModal: null }));
+ }
+ };
+ return html`
+
+
${STRINGS.get('Set Date/Time')}
+ setDt(ev.target.value)}/>
+
+
+
+ `;
+}
+
+function NotebookSettingsModal() {
+ const {state, setState, getNotebook, deleteNotebook} = useContext(AppContext);
+ const notebook = getNotebook(state.selectedNotebookId);
+ if (!notebook) return;
+ const [form, setForm] = useState({ ...notebook });
+ useEffect(() => {
+ setForm({ ...notebook });
+ }, [notebook.id]);
+ const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showNotebookSettings: false }));
+ const del = () => {
+ if (confirm('Delete?')) {
+ // if (notebook.sourceType==='local') {
+ deleteNotebook(notebook.id);
+ setState(s => ({ ...s, selectedNotebookId: null, showNotebookSettings: false }));
+ }
+ };
+ return html`
+
+
${STRINGS.get('Info/Settings')}
+
+
+
+
+
+
+ ${' '}
+ ${' '}
+ ${' '}
+
+
+ `;
+}
+
+function SearchModal() {
+ const {state, setState, getNotebook} = useContext(AppContext);
+ const {query, global} = state.searchModal;
+ const results = (global
+ ? state.notebooks.flatMap(notebook => (state.messages[notebook.id] || []).map(message => ({ ...message, notebook })))
+ : (state.messages[state.selectedNotebookId] || []).map(message => ({ ...message, notebook: getNotebook(state.selectedNotebookId) }))
+ ).filter(message => message.text.toLowerCase().includes(query.toLowerCase()));
+ const select = (notebookId, messageId) => setState(s => ({ ...s, selectedNotebookId: notebookId, searchModal: { ...s.searchModal, visible: false }, scrollToMessageId: messageId }));
+ return html`
+
+
${global ? 'Global' : 'Notebook'} Search
+
setState(s => ({ ...s, searchModal: { ...s.searchModal, query: ev.target.value }}))}/>
+ ${results.map(result => html`
+
select(result.notebook.id, result.id)}>
+ ${global && html`
+
${result.notebook.emoji}
+
${result.notebook.name}
+
`}
+
${result.text}
${new Date(result.created).toLocaleString()}
+
+ `)}
+
+
+ `;
+}
+
+function AppSettingsModal() {
+ const {state, setState} = useContext(AppContext);
+ const [importTxt, setImportTxt] = useState('');
+ const exportData = () => JSON.stringify({ notebooks: state.notebooks, messages: Object.fromEntries(Object.entries(state.encrypteds).map(([key, values]) => ([key, Object.values(values)]))) }, null, 2);
+ const doImport = () => {
+ try {
+ const obj = JSON.parse(importTxt);
+ if (obj.notebooks && obj.messages) {
+ setState(s => ({ ...s,
+ notebooks: obj.notebooks,
+ encrypteds: Object.fromEntries(Object.entries(obj.messages).map(([notebookId, messages]) => ([notebookId, Object.fromEntries(messages.map(message => [message.id, message]))]))),
+ }));
+ // window.location.reload();
+ setState(s => ({ ...s, showAppSettings:false }));
+ } else {
+ alert(STRINGS.get('Invalid data format'));
+ }
+ } catch (err) {
+ console.error(err);
+ alert(STRINGS.get('Invalid JSON syntax'));
+ }
+ };
+ return html`
+
+
${STRINGS.get('App Settings')}
+ ${STRINGS.get('Export Data')}
+ ${STRINGS.get('Import Data')}
+
+ `;
+}
+
+render(html`<${App} />`, document.body);
+
+};
\ No newline at end of file
diff --git a/icon.png b/icon.png
new file mode 100644
index 0000000..e57cae8
Binary files /dev/null and b/icon.png differ
diff --git a/index.html b/index.html
index 57d769b..edf5605 100644
--- a/index.html
+++ b/index.html
@@ -1,80 +1,93 @@
-
+
+
WhichNot
+
+
\ No newline at end of file
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..d84d85e
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,10 @@
+{
+ "name": " WhichNot",
+ "short_name": "WhichNot",
+ "scope": "https://whichnot.octt.eu.org/",
+ "start_url": "https://whichnot.octt.eu.org/",
+ "display": "standalone",
+ "icons": [
+ { "src": "./icon.png", "type": "image/png", "sizes": "1024x1024" }
+ ]
+}
\ No newline at end of file