Files
WhichNot/index.html
2025-04-21 22:46:58 +02:00

1930 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- <!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WhatsAppstyle Notes</title>
<style>
:root {
--whatsapp-green: #00a884;
--header-bg: #f0f2f5;
}
body, html {
margin: 0; height: 100%;
font-family: Arial, sans-serif;
}
.App { display: flex; height: 100vh; }
/* Chat list */
.ChatList {
width: 30%; background: white;
border-right: 1px solid #ddd; overflow-y: auto;
}
.ChatList-header {
display: flex; justify-content: space-between;
align-items: center; padding: 0.75rem 1rem;
border-bottom: 1px solid #ddd;
}
.ChatList-header button {
background: none; border: none;
font-size: 1.25rem; cursor: pointer;
}
.NotebookButton {
width: 100%; padding: 0.75rem 1rem;
background: none; border: none;
cursor: pointer; text-align: left;
}
.NotebookButton:hover { background: #f5f5f5; }
.NotebookTitle {
display: flex; align-items: center; gap: 0.5rem;
}
.NotebookEmoji {
width: 1.5rem; height: 1.5rem;
border-radius: 50%; display: flex;
align-items: center; justify-content: center;
font-size: 1rem;
}
.NotebookName { margin: 0; font-size: 1rem; }
.NotebookDescription {
font-size: 0.875rem; color: #555; margin: 0.25rem 0 0 2rem;
}
.NotebookPreview {
font-size: 0.875rem; color: #666;
margin: 0.25rem 0 0 2rem;
}
/* Chat screen */
.ChatScreen {
flex: 1; display: none; flex-direction: column;
background: #efeae2;
}
.App.show-chat .ChatScreen { display: flex; }
.ChatHeader {
background: var(--header-bg); padding: 0.5rem;
display: flex; align-items: center; gap: 0.5rem;
border-bottom: 1px solid #ddd; cursor: pointer;
}
.ChatHeader h3 {
margin: 0; flex: 1; font-size: 1rem;
}
.BackButton, .SearchButton {
font-size: 1.5rem; padding: 0.25rem;
background: none; border: none; cursor: pointer;
}
.Messages {
flex: 1; overflow-y: auto; padding: 1rem;
display: flex; flex-direction: column; gap: 0.5rem;
}
.Message {
background: white; padding: 0.5rem 1rem;
border-radius: 0.5rem; max-width: 70%;
word-break: break-word; margin: 0.5rem auto;
position: relative;
}
.Message .reactions {
display: flex; gap: 0.25rem; margin-top: 0.25rem;
}
.Message .reactions button {
background: #f5f5f5; border: none; border-radius: 0.25rem;
padding: 0 0.5rem; cursor: pointer;
}
.AddReactionBtn {
font-size: 0.9rem; background: none; border: none; cursor: pointer;
color: var(--whatsapp-green);
}
.ReactionInput {
width: 2rem; padding: 0.1rem; font-size: 1rem;
}
.Timestamp {
font-size: 0.75rem; color: #666;
margin-top: 0.25rem; text-align: right;
}
.SendBar {
display: flex; gap: 0.5rem; padding: 1rem;
background: white; border-top: 1px solid #ddd;
flex-direction: column;
}
.ReplyPreview {
background: #f5f5f5; padding: 0.5rem;
border-radius: 0.25rem; display: flex;
justify-content: space-between; align-items: center;
}
.EditArea {
flex: 1; padding: 0.5rem;
border: 1px solid #ddd; border-radius: 0.5rem;
resize: none;
}
.ContextMenu {
position: fixed; background: white;
border: 1px solid #ddd; border-radius: 0.25rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000; min-width: 140px;
}
.ContextMenuItem {
padding: 0.5rem 1rem; cursor: pointer;
}
.ContextMenuItem:hover { background: #f5f5f5; }
.DateTimeModal, .SearchModal, .AppSettingsModal,
.CreateModal, .CrossReplyModal {
position: fixed; top: 50%; left: 50%;
transform: translate(-50%,-50%);
background: white; padding: 2rem;
border-radius: 0.5rem; box-shadow: 0 0 1rem rgba(0,0,0,0.1);
max-height: 80vh; overflow-y: auto;
width: 90%; max-width: 400px; z-index: 1001;
}
.SearchModal input, .AppSettingsModal textarea,
.CreateModal input {
width: 100%; margin: 0.5rem 0;
padding: 0.5rem; border: 1px solid #ddd;
border-radius: 0.25rem;
}
.SearchResult {
padding: 0.5rem 0; border-bottom: 1px solid #eee;
cursor: pointer;
}
.SearchResult:hover { background: #f9f9f9; }
.ReplyIndicator {
border-left: 3px solid var(--whatsapp-green);
padding-left: 0.5rem; margin-bottom: 0.5rem;
color: #666; font-size: 0.9em; cursor: pointer;
}
@media (max-width: 768px) {
.ChatList { width: 100%; }
.ChatScreen { width: 100%; display: none; }
.App.show-chat .ChatList { display: none; }
.App.show-chat .ChatScreen { display: flex; }
}
</style>
</head>
<body> -->
<!-- <script type="module">
// import { Component, h, render, createContext } from 'https://esm.sh/preact';
// import { createRef } from 'https://esm.sh/preact/compat';
// import { useContext, useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact/hooks';
// import htm from 'https://esm.sh/htm';
// const html = htm.bind(h);
// window.Component = Component;
// window.createRef = createRef;
// window.createContext = createContext;
// window.useContext = useContext;
// window.useState = useState;
// window.useEffect = useEffect;
// window.useCallback = useCallback;
// window.useRef = useRef;
// window.render = render;
// window.html = html;
// for (const script of ["Launcher", "Settings", "SystemUI", "index3"]) {
// document.body.appendChild(Object.assign(document.createElement('link'), { rel: "stylesheet", href: `${script}.css` }));
// document.body.appendChild(Object.assign(document.createElement('script'), { src: `${script}.js` }));
// }
</script> -->
<!-- <script type="module">
import { h, render, createContext } from 'https://esm.sh/preact';
import { useState, useEffect, useCallback, useRef, useContext } from 'https://esm.sh/preact/hooks';
import htm from 'https://esm.sh/htm';
const html = htm.bind(h);
const AppContext = createContext();
const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
const randomEmoji = ()=>EMOJIS[Math.floor(Math.random()*EMOJIS.length)];
const randomColor = ()=>'#'+Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0');
const genUUID = ()=>crypto.randomUUID();//?.()||'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>((c==='x'?Math.random():Math.random()*0.4+0.6)|0).toString(16));
// simple linkify: wrap URLs in <a>
const linkify = text => {
const urlRegex = /(\bhttps?:\/\/[^\s]+)/g;
return text.replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
};
function App(){
const [state, setState] = useState({
notebooks: [],
messages: {},
selectedNotebook: null,
editingMessage: null,
showSettings: false,
showAppSettings: false,
createModal: false,
crossReplyModal: false,
crossReplySource: null,
contextMenu: { visible:false, messageIndex:null, x:0, y:0 },
dateTimeModal: null,
replyingTo: null,
searchModal: { visible:false, global:false, query:'' },
scrollToMessage: null,
reactionInputFor: null
});
// load notebooks & messages
useEffect(()=>{
const raw = JSON.parse(localStorage.getItem('notebooks')||'[]');
const nbs = raw.map(n=>({
...n,
nextMessageId: n.nextMessageId||1
}));
const msgs = {};
nbs.forEach(nb=>{
if(nb.sourceType==='local'){
msgs[nb.id] = JSON.parse(localStorage.getItem(`notebook-${nb.id}`)||'[]');
} else {
msgs[nb.id] = [];
fetch(`/${nb.id.slice(2)}/messages`)
.then(r=>r.json())
.then(data=>setState(s=>({
...s,
messages:{...s.messages,[nb.id]:data.messages||data}
}))).catch(console.error);
}
});
setState(s=>({...s, notebooks:nbs, messages:msgs}));
},[]);
// persist notebooks
useEffect(()=>{
localStorage.setItem('notebooks', JSON.stringify(state.notebooks));
},[state.notebooks]);
// click-away closes context
useEffect(()=>{
const h=e=>{
if(state.contextMenu.visible){
const menu=document.querySelector('.ContextMenu');
if(menu&&!menu.contains(e.target)){
setState(s=>({...s,contextMenu:{...s.contextMenu,visible:false}}));
}
}
};
document.addEventListener('click',h);
return ()=>document.removeEventListener('click',h);
},[state.contextMenu.visible]);
const createLocalNotebook = useCallback(()=>{
const id=genUUID(), now=Date.now();
const nb={ id, name:'New Notebook', emoji:randomEmoji(),
color:randomColor(), created:now,
nextMessageId:1, sourceType:'local',
description:'', parseMode:'plaintext'
};
localStorage.setItem(`notebook-${id}`,'[]');
setState(s=>({
...s,
notebooks:[...s.notebooks,nb],
messages:{...s.messages,[id]:[]},
createModal:false
}));
},[]);
const createRemoteNotebook = useCallback(()=>{
const rid=prompt('Enter remote notebook ID:');
if(!rid) return setState(s=>({...s,createModal:false}));
const id='//'+rid, now=Date.now();
const nb={ id, name:`Remote ${rid}`, emoji:randomEmoji(),
color:randomColor(), created:now,
nextMessageId:1, sourceType:'remote',
description:'', parseMode:'plaintext'
};
setState(s=>({
...s,
notebooks:[...s.notebooks,nb],
messages:{...s.messages,[id]:[]},
createModal:false
}));
fetch(`/${rid}/messages`)
.then(r=>r.json())
.then(data=>setState(s=>({
...s,
messages:{...s.messages,[id]:data.messages||data}
}))).catch(console.error);
},[]);
const openGlobalSearch=()=>setState(s=>({...s,searchModal:{visible:true,global:true,query:''}}));
const openNotebookSearch=()=>setState(s=>({...s,searchModal:{visible:true,global:false,query:''}}));
const persistMessages = useCallback((nbId,arr)=>{
const nb=state.notebooks.find(n=>n.id===nbId);
if(!nb) return;
if(nb.sourceType==='local'){
localStorage.setItem(`notebook-${nbId}`,JSON.stringify(arr));
} else {
fetch(`/${nbId.slice(2)}/messages`,{
method:'PUT',headers:{'Content-Type':'application/json'},
body:JSON.stringify(arr)
}).catch(console.error);
}
},[state.notebooks]);
return html`
<${AppContext.Provider} value=${{
state,setState,createLocalNotebook,createRemoteNotebook,
openGlobalSearch,openNotebookSearch,persistMessages
}}>
<div class="App ${state.selectedNotebook?'show-chat':''}">
<${ChatList}/>
<${ChatScreen}/>
${state.createModal && html`<${CreateModal}/>`}
${state.crossReplyModal && html`<${CrossReplyModal}/>`}
${state.showSettings && html`<${SettingsModal}/>`}
${state.showAppSettings && html`<${AppSettingsModal}/>`}
${state.contextMenu.visible && html`<${ContextMenu}/>`}
${state.dateTimeModal!==null && html`<${DateTimeModal}/>`}
${state.searchModal.visible && html`<${SearchModal}/>`}
</div>
<//>
`;
}
function ChatList(){
const {state,setState,openGlobalSearch} = useContext(AppContext);
return html`
<div class="ChatList">
<div class="ChatList-header">
<button onClick=${()=>setState(s=>({...s,createModal:true}))}></button>
<button onClick=${openGlobalSearch}>🔍</button>
<button onClick=${()=>setState(s=>({...s,showAppSettings:true}))}>⚙️</button>
</div>
${state.notebooks
.sort((a,b)=>{
const la=(state.messages[a.id]||[]).map(m=>m.timestamp)
.concat(a.created||0).reduce((mx,v)=>v>mx?v:mx,0);
const lb=(state.messages[b.id]||[]).map(m=>m.timestamp)
.concat(b.created||0).reduce((mx,v)=>v>mx?v:mx,0);
return lb-la;
})
.map(nb=>html`
<button class="NotebookButton" key=${nb.id}
onClick=${()=>setState(s=>({...s,selectedNotebook:nb.id}))}>
<div class="NotebookTitle">
<div class="NotebookEmoji" style=${{background:nb.color}}>${nb.emoji}</div>
<h4 class="NotebookName">${nb.name}</h4>
</div>
<div class="NotebookDescription">${nb.description||'<em>No description</em>'}</div>
<div class="NotebookPreview">
${(()=>{
const arr=state.messages[nb.id]||[];
if(!arr.length) return 'No messages';
const last=[...arr].sort((x,y)=>y.timestamp-x.timestamp)[0];
return last.content;
})()}
</div>
</button>
`)}
</div>
`;
}
function ChatScreen(){
const {state,setState,openNotebookSearch,persistMessages} = useContext(AppContext);
const inputRef = useRef();
const nb = state.notebooks.find(n=>n.id===state.selectedNotebook);
let messages = state.messages[nb?.id]||[];
messages = [...messages].sort((a,b)=>a.timestamp-b.timestamp);
// scroll when needed
useEffect(()=>{
if(state.scrollToMessage!=null){
const el=document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`);
if(el) el.scrollIntoView({behavior:'smooth',block:'center'});
setState(s=>({...s,scrollToMessage:null}));
}
},[state.scrollToMessage, state.selectedNotebook]);
const sendMessage = useCallback(()=>{
const content = inputRef.current.value.trim();
if(!content) return;
setState(prev=>{
const notebook = prev.notebooks.find(n=>n.id===prev.selectedNotebook);
const idNum = notebook.nextMessageId;
const newMsg = {
id:idNum, content, timestamp:Date.now(),
edited:false,
replyTo: prev.replyingTo && typeof prev.replyingTo==='object'
? prev.replyingTo
: prev.replyingTo!=null
? { notebookId: prev.selectedNotebook, id: prev.replyingTo }
: null,
reactions: []
};
let newArr, newNbs = prev.notebooks.map(n=>({...n}));
if(prev.editingMessage!=null){
newArr = prev.messages[prev.selectedNotebook].map(m=>
m.id===prev.messages[prev.selectedNotebook][prev.editingMessage].id
?{...m,content,edited:true}:m);
} else {
newArr = [...(prev.messages[prev.selectedNotebook]||[]),newMsg];
newNbs = newNbs.map(n=>n.id===prev.selectedNotebook
?{...n, nextMessageId:n.nextMessageId+1}:n);
}
persistMessages(prev.selectedNotebook,newArr);
return {
...prev,
messages:{...prev.messages,[prev.selectedNotebook]:newArr},
notebooks:newNbs,
editingMessage:null, replyingTo:null
};
});
inputRef.current.value='';
},[persistMessages]);
const openContext = (e,idx)=>{
e.preventDefault();
setState(s=>({
...s,
contextMenu:{ visible:true, messageIndex:idx, x:e.clientX, y:e.clientY }
}));
};
const addReaction = idx => {
setState(s=>({...s,reactionInputFor:idx}));
};
const confirmReaction = (idx,emoji) => {
setState(s=>{
const arr = s.messages[s.selectedNotebook]||[];
const msg = arr[idx];
if(!emoji) return {...s, reactionInputFor:null};
const newArr = arr.map((m,i)=> i===idx
?{...m, reactions: m.reactions.includes(emoji) ? m.reactions : [...m.reactions,emoji]}
:m
);
persistMessages(s.selectedNotebook,newArr);
return {...s, messages:{...s.messages,[s.selectedNotebook]:newArr}, reactionInputFor:null};
});
};
const removeReaction = (idx,emoji) => {
setState(s=>{
const arr = s.messages[s.selectedNotebook]||[];
const newArr = arr.map((m,i)=> i===idx
?{...m, reactions: m.reactions.filter(e=>e!==emoji)}
:m
);
persistMessages(s.selectedNotebook,newArr);
return {...s, messages:{...s.messages,[s.selectedNotebook]:newArr}};
});
};
if(!nb) return null;
return html`
<div class="ChatScreen">
<div class="ChatHeader" onClick=${()=>setState(s=>({...s,showSettings:true}))}>
<button class="BackButton"
onClick=${e=>{e.stopPropagation();setState(s=>({...s,selectedNotebook:null}));}}>
</button>
<div class="NotebookEmoji" style=${{background:nb.color}}>${nb.emoji}</div>
<h3>${nb.name}</h3>
<button class="SearchButton"
onClick=${e=>{e.stopPropagation();openNotebookSearch();}}>
🔍
</button>
</div>
<div class="Messages">
${messages.map((m,i)=>html`
<div class="Message" data-msg-id=${m.id}
onContextMenu=${e=>openContext(e,i)}
onTouchStart=${e=>{e.preventDefault();
const t=e.touches[0];
setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:t.clientX,y:t.clientY}}));
}}>
${m.replyTo && html`
<div class="ReplyIndicator"
onClick=${()=>setState(s=>({...s,scrollToMessage:m.replyTo.id}))}>
Reply to ${m.replyTo.notebookId!==nb.id
? state.notebooks.find(n=>n.id===m.replyTo.notebookId)?.name
: '…'}:
"${ (state.messages[m.replyTo.notebookId]||[])
.find(x=>x.id===m.replyTo.id)?.content || '' }"
</div>
`}
<div dangerouslySetInnerHTML=${{__html:linkify(m.content)}}/>
<div class="reactions">
${m.reactions.map(r=>html`
<button onClick=${()=>removeReaction(i,r)}>${r}</button>
`)}
${state.reactionInputFor===i
? html`<input class="ReactionInput" maxlength="2" autofocus
onKeyPress=${e=> e.key==='Enter' && (confirmReaction(i,e.target.value), e.target.value='')} />`
: html`<button class="AddReactionBtn" onClick=${()=>addReaction(i)}></button>`
}
</div>
<div class="Timestamp">
${new Date(m.timestamp).toLocaleString()}${m.edited?' (edited)':''}
</div>
</div>
`)}
</div>
<div class="SendBar">
${state.replyingTo && html`
<div class="ReplyPreview">
<span>Replying to: ${
(state.messages[ state.replyingTo.notebookId ]||[])
.find(x=>x.id===state.replyingTo.id)?.content || ''
}</span>
<button onClick=${()=>setState(s=>({...s,replyingTo:null}))}>×</button>
</div>
`}
<textarea ref=${inputRef} class="EditArea"
value=${state.editingMessage!=null
? messages[state.editingMessage]?.content
: ''}
onKeyPress=${e=>e.key==='Enter'&&!e.shiftKey&&sendMessage()} />
<button onClick=${sendMessage}>
${state.editingMessage!=null?'Save':'Send'}
</button>
</div>
</div>
`;
}
function CreateModal(){
const { createLocalNotebook, createRemoteNotebook, setState } = useContext(AppContext);
return html`
<div class="CreateModal">
<h3>Create Notebook</h3>
<button onClick=${createLocalNotebook}>Local Notebook</button>
<button onClick=${createRemoteNotebook}>Remote Notebook</button>
<button onClick=${()=>setState(s=>({...s,createModal:false}))}>Cancel</button>
</div>
`;
}
function CrossReplyModal(){
const { state, setState } = useContext(AppContext);
const { crossReplySource } = state;
return html`
<div class="CrossReplyModal">
<h3>Reply in Another Notebook</h3>
${state.notebooks.filter(n=>n.id!==crossReplySource.nb).map(n=>html`
<button onClick=${()=> {
setState(s=>({
...s,
selectedNotebook: n.id,
replyingTo: { notebookId: crossReplySource.nb, id: crossReplySource.id },
crossReplyModal: false
}));
}}>${n.emoji} ${n.name}</button>
`)}
<button onClick=${()=>setState(s=>({...s,crossReplyModal:false}))}>Cancel</button>
</div>
`;
}
function ContextMenu(){
const { state, setState } = useContext(AppContext);
const { persistMessages } = useContext(AppContext);
const idx = state.contextMenu.messageIndex;
const arr = state.messages[state.selectedNotebook]||[];
const msg = arr[idx];
const handle = action=>{
let newArr;
switch(action){
case 'reply':
setState(s=>({...s,replyingTo:{ notebookId:s.selectedNotebook, id:msg.id },contextMenu:{visible:false}})); return;
case 'edit':
setState(s=>({...s,editingMessage:arr.findIndex(m=>m.id===msg.id),contextMenu:{visible:false}})); return;
case 'datetime':
setState(s=>({...s,dateTimeModal:arr.findIndex(m=>m.id===msg.id),contextMenu:{visible:false}})); return;
case 'delete':
newArr = arr.filter(m=>m.id!==msg.id);
persistMessages(state.selectedNotebook,newArr);
setState(s=>({
...s,
messages:{...s.messages,[s.selectedNotebook]:newArr},
contextMenu:{visible:false}
})); return;
case 'cross-reply':
setState(s=>({
...s,
contextMenu:{visible:false},
crossReplyModal:true,
crossReplySource:{ nb: state.selectedNotebook, id: msg.id }
})); return;
}
};
return html`
<div class="ContextMenu" style=${`left:${state.contextMenu.x}px;top:${state.contextMenu.y}px`}>
<div class="ContextMenuItem" onClick=${()=>handle('reply')}>Reply</div>
<div class="ContextMenuItem" onClick=${()=>handle('cross-reply')}>Reply in Another Notebook</div>
<div class="ContextMenuItem" onClick=${()=>handle('edit')}>Edit</div>
<div class="ContextMenuItem" onClick=${()=>handle('datetime')}>Set Date/Time</div>
<div class="ContextMenuItem" onClick=${()=>handle('delete')}>Delete</div>
</div>
`;
}
function DateTimeModal(){
const { state, setState, persistMessages } = useContext(AppContext);
const idx = state.dateTimeModal;
const arr = state.messages[state.selectedNotebook]||[];
const msg = arr[idx];
const [dt,setDt] = useState('');
useEffect(()=>{ if(msg) setDt(new Date(msg.timestamp).toISOString().slice(0,16)); },[msg]);
const save=()=>{
const ts=new Date(dt).getTime();
if(!isNaN(ts)){
const newArr = arr.map(m=>m.id===msg.id?{...m,timestamp:ts}:m);
persistMessages(state.selectedNotebook,newArr);
setState(s=>({...s,messages:{...s.messages,[s.selectedNotebook]:newArr},dateTimeModal:null}));
}
};
return html`
<div class="DateTimeModal">
<h3>Set Date/Time</h3>
<input type="datetime-local" value=${dt} onChange=${e=>setDt(e.target.value)}/>
<button onClick=${save}>Save</button>
<button onClick=${()=>setState(s=>({...s,dateTimeModal:null}))}>Cancel</button>
</div>
`;
}
function SettingsModal(){
const { state, setState } = useContext(AppContext);
const nb = state.notebooks.find(n=>n.id===state.selectedNotebook);
const [form,setForm] = useState({...nb});
const save=()=>{
setState(s=>({
...s,
notebooks:s.notebooks.map(n=>n.id===nb.id?form:n),
showSettings:false
}));
};
const del=()=>{
if(confirm('Delete this notebook?')){
if(nb.sourceType==='local') localStorage.removeItem(`notebook-${nb.id}`);
setState(s=>({
...s,
notebooks:s.notebooks.filter(n=>n.id!==nb.id),
messages:{...s.messages,[nb.id]:undefined},
selectedNotebook:null,
showSettings:false
}));
}
};
return html`
<div class="CreateModal">
<h3>Notebook Settings</h3>
<label>Name:<br/><input value=${form.name}
onChange=${e=>setForm(f=>({...f,name:e.target.value}))}/></label><br/>
<label>Emoji:<br/><input value=${form.emoji} maxLength="2"
onChange=${e=>setForm(f=>({...f,emoji:e.target.value}))}/></label><br/>
<label>Color:<br/><input type="color" value=${form.color}
onChange=${e=>setForm(f=>({...f,color:e.target.value}))}/></label><br/>
<label>Description:<br/><input value=${form.description}
onChange=${e=>setForm(f=>({...f,description:e.target.value}))}/></label><br/>
<label>Parse Mode:<br/>
<select value=${form.parseMode}
onChange=${e=>setForm(f=>({...f,parseMode:e.target.value}))}>
<option value="plaintext">Plaintext</option>
</select>
</label><br/><br/>
<button onClick=${save}>Save</button>
<button onClick=${del} style="color:red">Delete</button>
<button onClick=${()=>setState(s=>({...s,showSettings:false}))}>Close</button>
</div>
`;
}
function SearchModal(){
const { state, setState } = useContext(AppContext);
const { query, global } = state.searchModal;
const all = global
? state.notebooks.flatMap(nb=>
(state.messages[nb.id]||[]).map(m=>({...m, notebook:nb})))
: (state.messages[state.selectedNotebook]||[])
.map(m=>({...m, notebook:state.notebooks.find(n=>n.id===state.selectedNotebook)}));
const results = all.filter(m=>m.content.toLowerCase().includes(query.toLowerCase()));
const select=(nbId,mId)=>{
setState(s=>({
...s,
selectedNotebook: nbId,
searchModal:{...s.searchModal,visible:false},
scrollToMessage: mId
}));
};
return html`
<div class="SearchModal">
<h3>${global?'Global':'Notebook'} Search</h3>
<input placeholder="Search..." value=${query}
onInput=${e=>setState(s=>({...s,searchModal:{...s.searchModal,query:e.target.value}}))}/>
${results.map(r=>html`
<div class="SearchResult" onClick=${()=>select(r.notebook.id,r.id)}>
${global && html`
<div class="NotebookTitle">
<div class="NotebookEmoji" style=${{background:r.notebook.color}}>${r.notebook.emoji}</div>
<strong>${r.notebook.name}</strong>
</div>`}
<div>${r.content}</div>
<em>${new Date(r.timestamp).toLocaleString()}</em>
</div>
`)}
<button onClick=${()=>setState(s=>({...s,searchModal:{...s.searchModal,visible:false}}))}>Close</button>
</div>
`;
}
function AppSettingsModal(){
const { state, setState } = useContext(AppContext);
const exportData=()=>JSON.stringify({
notebooks:state.notebooks,
messages:state.notebooks.reduce((acc,nb)=>({
...acc, [nb.id]: JSON.parse(localStorage.getItem(`notebook-${nb.id}`)||'[]')
}),{})
},null,2);
const [txt,setTxt]=useState('');
const doImport=()=>{
try{
const obj=JSON.parse(txt);
if(obj.notebooks&&obj.messages){
localStorage.setItem('notebooks',JSON.stringify(obj.notebooks));
Object.entries(obj.messages).forEach(([id,arr])=>{
localStorage.setItem(`notebook-${id}`,JSON.stringify(arr));
});
window.location.reload();
} else alert('Invalid format');
} catch{ alert('Invalid JSON'); }
};
return html`
<div class="AppSettingsModal">
<h3>App Settings</h3>
<h4>Export Data</h4>
<textarea readonly rows="8">${exportData()}</textarea>
<h4>Import Data</h4>
<textarea rows="6" placeholder="Paste JSON here"
onInput=${e=>setTxt(e.target.value)}/>
<button onClick=${doImport}>Import</button>
<button onClick=${()=>setState(s=>({...s,showAppSettings:false}))}>Close</button>
</div>
`;
}
render(html`<${App}/>`, document.body);
</script>
</body>
</html> -->
<!--
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WhichNot: WhatsAppstyle Notes</title>
<style>
:root {
--whatsapp-green: #00a884;
--header-bg: #f0f2f5;
}
body, html { margin: 0; height: 100%; font-family: Arial, sans-serif; }
.App { display: flex; height: 100vh; }
.ChatList { width: 30%; background: white; border-right:1px solid #ddd; overflow-y:auto; }
.ChatList-header { display:flex; justify-content:space-between; align-items:center; padding:.75rem 1rem; border-bottom:1px solid #ddd; }
.ChatList-header button { background:none; border:none; font-size:1.25rem; cursor:pointer; }
.NotebookButton { width:100%; padding:.75rem 1rem; background:none; border:none; cursor:pointer; text-align:left; }
.NotebookButton:hover { background:#f5f5f5; }
.NotebookTitle { display:flex; align-items:center; gap:.5rem; }
.NotebookEmoji { width:1.5rem; height:1.5rem; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:1rem; }
.NotebookName { margin:0; font-size:1rem; }
.NotebookDescription { font-size:.875rem; color:#555; margin:.25rem 0 0 2rem; }
.NotebookPreview { font-size:.875rem; color:#666; margin:.25rem 0 0 2rem; }
.ChatScreen { flex:1; display:none; flex-direction:column; background:#efeae2; }
.App.show-chat .ChatScreen { display:flex; }
.ChatHeader { background:var(--header-bg); padding:.5rem; display:flex; align-items:center; gap:.5rem; border-bottom:1px solid #ddd; cursor:pointer; }
.ChatHeader h3 { margin:0; flex:1; font-size:1rem; }
.BackButton, .SearchButton { font-size:1.5rem; padding:.25rem; background:none; border:none; cursor:pointer; }
.Messages { flex:1; overflow-y:auto; padding:1rem; display:flex; flex-direction:column; gap:.5rem; }
.Message { background:white; padding:.5rem 1rem; border-radius:.5rem; max-width:70%; word-break:break-word; margin:.5rem auto; position:relative; }
.Message .reactions { display:flex; gap:.25rem; margin-top:.25rem; }
.Message .reactions button { background:#f5f5f5; border:none; border-radius:.25rem; padding:0 .5rem; cursor:pointer; }
.AddReactionBtn { font-size:.9rem; background:none; border:none; cursor:pointer; color:var(--whatsapp-green); }
.ReactionInput { width:2rem; padding:.1rem; font-size:1rem; }
.Timestamp { font-size:.75rem; color:#666; margin-top:.25rem; text-align:right; }
.SendBar { display:flex; gap:.5rem; padding:1rem; background:white; border-top:1px solid #ddd; flex-direction:column; }
.ReplyPreview { background:#f5f5f5; 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; }
.ContextMenu { position:fixed; background:white; border:1px solid #ddd; border-radius:.25rem; box-shadow:0 2px 8px rgba(0,0,0,0.1); z-index:1000; min-width:140px; }
.ContextMenuItem { padding:.5rem 1rem; cursor:pointer; }
.ContextMenuItem:hover { background:#f5f5f5; }
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .CrossReplyModal {
position:fixed; top:50%; left:50%; transform:translate(-50%,-50%);
background:white; padding:2rem; border-radius:.5rem; box-shadow:0 0 1rem rgba(0,0,0,0.1);
max-height:80vh; overflow-y:auto; width:90%; max-width:400px; z-index:1001;
}
.SearchModal input, .AppSettingsModal textarea, .CreateModal input {
width:100%; margin:.5rem 0; padding:.5rem; border:1px solid #ddd; border-radius:.25rem;
}
.SearchResult { padding:.5rem 0; border-bottom:1px solid #eee; cursor:pointer; }
.SearchResult:hover { background:#f9f9f9; }
.ReplyIndicator { border-left:3px solid var(--whatsapp-green); padding-left:.5rem; margin-bottom:.5rem; color:#666; font-size:.9em; cursor:pointer; }
@media (max-width:768px) {
.ChatList { width:100%; }
.ChatScreen { width:100%; display:none; }
.App.show-chat .ChatList { display:none; }
.App.show-chat .ChatScreen { display:flex; }
}
</style>
</head>
<body>
<script type="module">
import { h, render, createContext } from 'https://esm.sh/preact';
import { useState, useEffect, useCallback, useRef, useContext } from 'https://esm.sh/preact/hooks';
import htm from 'https://esm.sh/htm';
const html = htm.bind(h), AppContext = createContext();
// Crypto Helpers (AES-GCM + Ed25519 + HKDF)
async function genAESKey(){ return crypto.subtle.generateKey({name:'AES-GCM',length:256},true,['encrypt','decrypt']); }
async function genEd25519(){ return crypto.subtle.generateKey({name:'Ed25519',namedCurve:'Ed25519'},true,['sign','verify']); }
async function exportJWK(key){ return btoa(JSON.stringify(await crypto.subtle.exportKey('jwk',key))); }
async function importJWK(b64,alg,usages){ const jwk=JSON.parse(atob(b64)); return crypto.subtle.importKey('jwk',jwk,alg,true,usages); }
function randBytes(n=12){ const b=new Uint8Array(n);crypto.getRandomValues(b);return b; }
function bufToB64(buf){ return btoa(String.fromCharCode(...new Uint8Array(buf))); }
function b64ToBuf(s){ return Uint8Array.from(atob(s),c=>c.charCodeAt(0)); }
async function deriveKey(raw, salt){
const hk=await crypto.subtle.importKey('raw',raw,'HKDF',false,['deriveKey']);
return crypto.subtle.deriveKey({name:'HKDF',salt,info:new TextEncoder().encode('msg'),hash:'SHA-256'}, hk,{name:'AES-GCM',length:256},true,['encrypt','decrypt']);
}
const linkify=txt=>txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'<a href="$1" target="_blank">$1</a>');
// App Component
function App(){
const [state,setState] = useState({
notebooks:[], encrypted:{}, messages:{},
selectedNotebook:null, editingMessage:null,
showSettings:false, showAppSettings:false,
createModal:false, crossReplyModal:false, crossReplySource:null,
contextMenu:{visible:false,messageIndex:null,x:0,y:0},
dateTimeModal:null, replyingTo:null,
searchModal:{visible:false,global:false,query:''},
scrollToMessage:null, reactionInputFor:null
});
const inputRef = useRef();
// Load from localStorage & decrypt
useEffect(()=>{
const raw=JSON.parse(localStorage.getItem('notebooks')||'[]'),
enc={}, msgs={};
(async()=>{
for(const nb of raw){
const arr=JSON.parse(localStorage.getItem(`notebook-${nb.id}`)||'[]');
enc[nb.id]=arr;
const aes=await importJWK(nb.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']),
rawKey=await crypto.subtle.exportKey('raw',aes),
plain=[];
for(const e of arr){
const salt=b64ToBuf(e.salt), iv=b64ToBuf(e.iv),
key=await deriveKey(rawKey,salt),
ct=b64ToBuf(e.ciphertext),
dec=await crypto.subtle.decrypt({name:'AES-GCM',iv},key,ct);
plain.push({...e,content:new TextDecoder().decode(dec)});
}
msgs[nb.id]=plain;
}
setState(s=>({...s,notebooks:raw,encrypted:enc,messages:msgs}));
})();
},[]);
// Persist notebooks meta & encrypted store
useEffect(()=>localStorage.setItem('notebooks',JSON.stringify(state.notebooks)),[state.notebooks]);
useEffect(()=>{
for(const id in state.encrypted) localStorage.setItem(`notebook-${id}`,JSON.stringify(state.encrypted[id]));
},[state.encrypted]);
// Close context menu
useEffect(()=>{
const h=e=>{
if(state.contextMenu.visible){
const m=document.querySelector('.ContextMenu');
if(m&&!m.contains(e.target)) setState(s=>({...s,contextMenu:{...s.contextMenu,visible:false}}));
}
};
document.addEventListener('click',h);
return ()=>document.removeEventListener('click',h);
},[state.contextMenu.visible]);
// Create notebook
const createNotebook=useCallback(async(type)=>{
let id=type==='local'?crypto.randomUUID():prompt('Remote ID:');
if(!id) return;
const now=Date.now(), aes=await genAESKey(), ed=await genEd25519();
const aesB64=await exportJWK(aes), privB64=await exportJWK(ed.privateKey), pubB64=await exportJWK(ed.publicKey);
const nb={ id,name:'New Notebook',emoji:'📒',
color:'#'+Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0'),
sourceType:type,description:'',parseMode:'plaintext',
nextMessageId:1,created:now,
aesKeyB64:aesB64,edPrivB64:privB64,edPubB64:pubB64
};
setState(s=>({
...s,
notebooks:[...s.notebooks,nb],
encrypted:{...s.encrypted,[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]);
// Persist messages (encrypt+sync)
const persistMessages=useCallback(async(nbId, plainArr)=>{
const nb=state.notebooks.find(n=>n.id===nbId); if(!nb) return;
const aes=await importJWK(nb.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']),
rawKey=await crypto.subtle.exportKey('raw',aes),
encArr=[];
for(const m of plainArr){
const salt=randBytes(), iv=randBytes(12),
key=await deriveKey(rawKey,salt),
ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.content));
encArr.push({
id:m.id, salt:bufToB64(salt), iv:bufToB64(iv),
ciphertext:bufToB64(ct), timestamp:m.timestamp,
edited:m.edited, replyTo:m.replyTo, reactions:m.reactions
});
}
setState(s=>({...s,encrypted:{...s.encrypted,[nbId]:encArr},messages:{...s.messages,[nbId]:plainArr}}));
if(nb.sourceType==='remote'){
const priv=await importJWK(nb.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:nb.edPubB64})
});
}
},[state.notebooks]);
// Reactions
const addReaction=useCallback(idx=>setState(s=>({...s,reactionInputFor:idx})),[]);
const confirmReaction=useCallback(async(idx,emoji)=>{
if(!emoji) return setState(s=>({...s,reactionInputFor:null}));
const nbId=state.selectedNotebook, arr=state.messages[nbId]||[];
const newArr=arr.map((m,i)=>i===idx?{...m,reactions:m.reactions.includes(emoji)?m.reactions:[...m.reactions,emoji]}:m);
await persistMessages(nbId,newArr);
setState(s=>({...s,reactionInputFor:null}));
},[state.selectedNotebook,state.messages,persistMessages]);
const removeReaction=useCallback(async(idx,emoji)=>{
const nbId=state.selectedNotebook, arr=state.messages[nbId]||[];
const newArr=arr.map((m,i)=>i===idx?{...m,reactions:m.reactions.filter(r=>r!==emoji)}:m);
await persistMessages(nbId,newArr);
},[state.selectedNotebook,state.messages,persistMessages]);
// Prefill on edit
useEffect(()=>{
if(state.editingMessage!=null && inputRef.current){
const nbId=state.selectedNotebook;
const m=state.messages[nbId]?.[state.editingMessage];
if(m) inputRef.current.value=m.content;
}
},[state.editingMessage, state.selectedNotebook]);
// Send or save message
const sendMessage=useCallback(async()=>{
const nbId=state.selectedNotebook; if(!nbId) return;
const txt=inputRef.current.value.trim(); if(!txt) return;
const arr=state.messages[nbId]||[], nb=state.notebooks.find(n=>n.id===nbId);
let newArr;
if(state.editingMessage!=null){
// Editing: preserve id & timestamp
newArr = arr.map((m,i)=> i===state.editingMessage
? {...m, content:txt, edited:true}
: m
);
setState(s=>({...s,editingMessage:null,replyingTo:null}));
} else {
// New message
const m={ id:nb.nextMessageId, content:txt, timestamp:Date.now(), edited:false, replyTo:state.replyingTo, reactions:[] };
newArr=[...arr,m];
setState(s=>({
...s,
notebooks:s.notebooks.map(n=>n.id===nbId?{...n,nextMessageId:n.nextMessageId+1}:n),
replyingTo:null
}));
}
inputRef.current.value='';
await persistMessages(nbId,newArr);
},[state.selectedNotebook, state.editingMessage, state.replyingTo, state.messages, state.notebooks]);
return html`
<${AppContext.Provider} value=${{
state,setState,createNotebook,sendMessage,persistMessages,
addReaction,confirmReaction,removeReaction
}}>
<div class="App ${state.selectedNotebook?'show-chat':''}">
<${ChatList}/>
<${ChatScreen} inputRef=${inputRef}/>
${state.createModal && html`<${CreateModal}/>`}
${state.crossReplyModal && html`<${CrossReplyModal}/>`}
${state.showSettings && html`<${SettingsModal}/>`}
${state.showAppSettings && html`<${AppSettingsModal}/>`}
${state.contextMenu.visible && html`<${ContextMenu}/>`}
${state.dateTimeModal!==null && html`<${DateTimeModal}/>`}
${state.searchModal.visible && html`<${SearchModal}/>`}
</div>
<//>
`;
}
// ChatList
function ChatList(){
const {state,setState} = useContext(AppContext);
return html`
<div class="ChatList">
<div class="ChatList-header">
<button onClick=${()=>setState(s=>({...s,createModal:true}))}></button>
<button onClick=${()=>setState(s=>({...s,searchModal:{visible:true,global:true,query:''}}))}>🔍</button>
<button onClick=${()=>setState(s=>({...s,showAppSettings:true}))}>⚙️</button>
</div>
${state.notebooks.sort((a,b)=>{
const ta=Math.max(a.created,...(state.messages[a.id]||[]).map(m=>m.timestamp));
const tb=Math.max(b.created,...(state.messages[b.id]||[]).map(m=>m.timestamp));
return tb-ta;
}).map(nb=>html`
<button class="NotebookButton" key=${nb.id} onClick=${()=>setState(s=>({...s,selectedNotebook:nb.id}))}>
<div class="NotebookTitle">
<div class="NotebookEmoji" style=${{background:nb.color}}>${nb.emoji}</div>
<h4 class="NotebookName">${nb.name}</h4>
</div>
<div class="NotebookDescription">${nb.description||'<em>No description</em>'}</div>
<div class="NotebookPreview">${(()=>{const a=state.messages[nb.id]||[];return a.length?a[a.length-1].content:'No messages';})()}</div>
</button>
`)}
</div>
`;
}
// ChatScreen
function ChatScreen({inputRef}){
const {state,setState,sendMessage,addReaction,confirmReaction,removeReaction} = useContext(AppContext);
const nb=state.notebooks.find(n=>n.id===state.selectedNotebook);
const msgs=(state.messages[nb?.id]||[]).slice().sort((a,b)=>a.timestamp-b.timestamp);
useEffect(()=>{
if(state.scrollToMessage!=null){
const el=document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`);
if(el) el.scrollIntoView({behavior:'smooth',block:'center'});
setState(s=>({...s,scrollToMessage:null}));
}
},[state.scrollToMessage, state.selectedNotebook]);
if(!nb) return null;
return html`
<div class="ChatScreen">
<div class="ChatHeader" onClick=${()=>setState(s=>({...s,showSettings:true}))}>
<button class="BackButton" onClick=${e=>{e.stopPropagation();setState(s=>({...s,selectedNotebook:null}));}}>←</button>
<div class="NotebookEmoji" style=${{background:nb.color}}>${nb.emoji}</div>
<h3>${nb.name}</h3>
<button class="SearchButton" onClick=${e=>{e.stopPropagation();setState(s=>({...s,searchModal:{visible:true,global:false,query:''}}));}}>🔍</button>
</div>
<div class="Messages">
${msgs.map((m,i)=>html`
<div class="Message" data-msg-id=${m.id}
onContextMenu=${e=>{e.preventDefault();setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:e.clientX,y:e.clientY}}));}}
onTouchStart=${e=>{e.preventDefault();const t=e.touches[0];setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:t.clientX,y:t.clientY}}));}}>
${m.replyTo&&html`
<div class="ReplyIndicator" onClick=${()=>setState(s=>({
selectedNotebook:m.replyTo.notebookId,
scrollToMessage:m.replyTo.id
}))}>
Reply to "${(state.messages[m.replyTo.notebookId]||[]).find(x=>x.id===m.replyTo.id)?.content||''}"
</div>`}
<div dangerouslySetInnerHTML=${{__html:linkify(m.content)}}/>
<div class="reactions">
${m.reactions.map(r=>html`<button onClick=${()=>removeReaction(i,r)}>${r}</button>`)}
${state.reactionInputFor===i
? html`<input class="ReactionInput" maxlength="2" autofocus onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(i,e.target.value), e.target.value='')} />`
: html`<button class="AddReactionBtn" onClick=${()=>addReaction(i)}></button>`}
</div>
<div class="Timestamp">${new Date(m.timestamp).toLocaleString()}${m.edited?' (edited)':''}</div>
</div>
`)}
</div>
<div class="SendBar">
${state.replyingTo&&html`
<div class="ReplyPreview">
<span>Replying to: ${(state.messages[state.replyingTo.notebookId]||[]).find(x=>x.id===state.replyingTo.id)?.content||''}</span>
<button onClick=${()=>setState(s=>({...s,replyingTo:null}))}>×</button>
</div>`}
<textarea ref=${inputRef} class="EditArea" onKeyPress=${e=>e.key==='Enter'&&!e.shiftKey&&sendMessage()}/>
<button onClick=${sendMessage}>${state.editingMessage!=null?'Save':'Send'}</button>
</div>
</div>
`;
}
// CreateModal
function CreateModal(){
const {createNotebook,setState} = useContext(AppContext);
return html`
<div class="CreateModal">
<h3>Create Notebook</h3>
<button onClick=${()=>createNotebook('local')}>Local Notebook</button>
<button onClick=${()=>createNotebook('remote')}>Remote Notebook</button>
<button onClick=${()=>setState(s=>({...s,createModal:false}))}>Cancel</button>
</div>
`;
}
// CrossReplyModal
function CrossReplyModal(){
const {state,setState} = useContext(AppContext);
return html`
<div class="CrossReplyModal">
<h3>Reply in Another Notebook</h3>
${state.notebooks.filter(n=>n.id!==state.crossReplySource.nb).map(n=>html`
<button onClick=${()=>setState(s=>({
...s,
selectedNotebook:n.id,
replyingTo:{notebookId:s.crossReplySource.nb,id:s.crossReplySource.id},
crossReplyModal:false
}))}>${n.emoji} ${n.name}</button>
`)}
<button onClick=${()=>setState(s=>({...s,crossReplyModal:false}))}>Cancel</button>
</div>
`;
}
// ContextMenu
function ContextMenu(){
const {state,setState,persistMessages} = useContext(AppContext);
const idx=state.contextMenu.messageIndex, nbId=state.selectedNotebook;
const arr=state.messages[nbId]||[], msg=arr[idx];
const handle=action=>{
let newArr;
switch(action){
case 'reply':
setState(s=>({...s,replyingTo:{notebookId:nbId,id:msg.id},contextMenu:{...s.contextMenu,visible:false}}));
return;
case 'cross-reply':
setState(s=>({...s,contextMenu:{...s.contextMenu,visible:false},crossReplyModal:true,crossReplySource:{nb:nbId,id:msg.id}}));
return;
case 'edit':
setState(s=>({...s,editingMessage:idx,contextMenu:{...s.contextMenu,visible:false}}));
return;
case 'datetime':
setState(s=>({...s,dateTimeModal:idx,contextMenu:{...s.contextMenu,visible:false}}));
return;
case 'delete':
newArr=arr.filter((_,i)=>i!==idx);
persistMessages(nbId,newArr);
setState(s=>({...s,messages:{...s.messages,[nbId]:newArr},contextMenu:{...s.contextMenu,visible:false}}));
return;
}
};
return html`
<div class="ContextMenu" style=${`left:${state.contextMenu.x}px;top:${state.contextMenu.y}px`}>
<div class="ContextMenuItem" onClick=${()=>handle('reply')}>Reply</div>
<div class="ContextMenuItem" onClick=${()=>handle('cross-reply')}>Reply in Another Notebook</div>
<div class="ContextMenuItem" onClick=${()=>handle('edit')}>Edit</div>
<div class="ContextMenuItem" onClick=${()=>handle('datetime')}>Set Date/Time</div>
<div class="ContextMenuItem" onClick=${()=>handle('delete')}>Delete</div>
</div>
`;
}
// DateTimeModal
function DateTimeModal(){
const {state,setState,persistMessages} = useContext(AppContext);
const idx=state.dateTimeModal, nbId=state.selectedNotebook;
const arr=state.messages[nbId]||[], msg=arr[idx];
const [dt,setDt]=useState('');
useEffect(()=>{ if(msg) setDt(new Date(msg.timestamp).toISOString().slice(0,16)); },[msg]);
const save=()=>{
const ts=new Date(dt).getTime();
if(!isNaN(ts)){
const newArr=arr.map((m,i)=>i===idx?{...m,timestamp:ts}:m);
persistMessages(nbId,newArr);
setState(s=>({...s,messages:{...s.messages,[nbId]:newArr},dateTimeModal:null}));
}
};
return html`
<div class="DateTimeModal">
<h3>Set Date/Time</h3>
<input type="datetime-local" value=${dt} onChange=${e=>setDt(e.target.value)}/>
<button onClick=${save}>Save</button>
<button onClick=${()=>setState(s=>({...s,dateTimeModal:null}))}>Cancel</button>
</div>
`;
}
// SettingsModal
function SettingsModal(){
const {state,setState} = useContext(AppContext);
const nb=state.notebooks.find(n=>n.id===state.selectedNotebook);
const [form,setForm]=useState({...nb});
const save=()=>setState(s=>({...s,notebooks:s.notebooks.map(n=>n.id===nb.id?form:n),showSettings:false}));
const del=()=>{
if(confirm('Delete?')){
if(nb.sourceType==='local') localStorage.removeItem(`notebook-${nb.id}`);
setState(s=>({...s,notebooks:s.notebooks.filter(n=>n.id!==nb.id),messages:{...s.messages,[nb.id]:undefined},encrypted:{...s.encrypted,[nb.id]:undefined},selectedNotebook:null,showSettings:false}));
}
};
return html`
<div class="CreateModal">
<h3>Settings</h3>
<label>Name:<input value=${form.name} onChange=${e=>setForm(f=>({...f,name:e.target.value}))}/></label><br/>
<label>Emoji:<input value=${form.emoji} maxLength="2" onChange=${e=>setForm(f=>({...f,emoji:e.target.value}))}/></label><br/>
<label>Color:<input type="color" value=${form.color} onChange=${e=>setForm(f=>({...f,color:e.target.value}))}/></label><br/>
<label>Description:<input value=${form.description} onChange=${e=>setForm(f=>({...f,description:e.target.value}))}/></label><br/>
<label>Parse Mode:<select value=${form.parseMode} onChange=${e=>setForm(f=>({...f,parseMode:e.target.value}))}><option value="plaintext">Plaintext</option></select></label><br/><br/>
<button onClick=${save}>Save</button>
<button onClick=${del} style="color:red">Delete</button>
<button onClick=${()=>setState(s=>({...s,showSettings:false}))}>Close</button>
</div>
`;
}
// SearchModal
function SearchModal(){
const {state,setState} = useContext(AppContext);
const {query,global} = state.searchModal;
const all=global
? state.notebooks.flatMap(nb=>(state.messages[nb.id]||[]).map(m=>({...m,notebook:nb})))
: (state.messages[state.selectedNotebook]||[]).map(m=>({...m,notebook:state.notebooks.find(n=>n.id===state.selectedNotebook)}));
const results=all.filter(m=>m.content.toLowerCase().includes(query.toLowerCase()));
const select=(nbId,mId)=>setState(s=>({...s,selectedNotebook:nbId,searchModal:{...s.searchModal,visible:false},scrollToMessage:mId}));
return html`
<div class="SearchModal">
<h3>${global?'Global':'Notebook'} Search</h3>
<input placeholder="Search..." value=${query} onInput=${e=>setState(s=>({...s,searchModal:{...s.searchModal,query:e.target.value}}))}/>
${results.map(r=>html`
<div class="SearchResult" onClick=${()=>select(r.notebook.id,r.id)}>
${global&&html`<div class="NotebookTitle"><div class="NotebookEmoji" style=${{background:r.notebook.color}}>${r.notebook.emoji}</div><strong>${r.notebook.name}</strong></div>`}
<div>${r.content}</div><em>${new Date(r.timestamp).toLocaleString()}</em>
</div>
`)}
<button onClick=${()=>setState(s=>({...s,searchModal:{...s.searchModal,visible:false}}))}>Close</button>
</div>
`;
}
// AppSettingsModal
function AppSettingsModal(){
const {state,setState} = useContext(AppContext);
const exportData=()=>JSON.stringify({notebooks:state.notebooks,encrypted:state.encrypted},null,2);
const [txt,setTxt]=useState('');
const doImport=()=>{
try{
const obj=JSON.parse(txt);
if(obj.notebooks&&obj.encrypted){
localStorage.setItem('notebooks',JSON.stringify(obj.notebooks));
Object.entries(obj.encrypted).forEach(([id,arr])=>localStorage.setItem(`notebook-${id}`,JSON.stringify(arr)));
window.location.reload();
} else alert('Invalid');
} catch{ alert('Bad JSON'); }
};
return html`
<div class="AppSettingsModal">
<h3>App Settings</h3>
<h4>Export Data</h4><textarea readonly rows="8">${exportData()}</textarea>
<h4>Import Data</h4><textarea rows="6" placeholder="Paste JSON" onInput=${e=>setTxt(e.target.value)}/>
<button onClick=${doImport}>Import</button>
<button onClick=${()=>setState(s=>({...s,showAppSettings:false}))}>Close</button>
</div>
`;
}
render(html`<${App}/>`, document.body);
</script>
</body>
</html>
-->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhichNot</title>
<style>
:root {
--whatsapp-green: #00a884;
--header-bg: #f0f2f5;
}
body, html {
margin: 0; height: 100%;
font-family: Arial, sans-serif;
}
.App { display: flex; height: 100vh; }
.ChatList {
width: 30%; overflow-y: auto;
background: white; border-right: 1px solid #ddd;
}
.ChatList-header { display:flex; justify-content:space-between; align-items:center; padding:.75rem 1rem; border-bottom:1px solid #ddd; }
.ChatList-header button { background:none; border:none; font-size:1.25rem; cursor:pointer; }
.NotebookButton { width:100%; padding:.75rem 1rem; background:none; border:none; cursor:pointer; text-align:left; }
.NotebookButton:hover { background:#f5f5f5; }
.NotebookTitle { display:flex; align-items:center; gap:.5rem; }
.NotebookEmoji { width:1.5rem; height:1.5rem; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:1rem; }
.NotebookName { margin:0; font-size:1rem; }
.NotebookDescription { font-size:.875rem; color:#555; margin:.25rem 0 0 2rem; }
.NotebookPreview { font-size:.875rem; color:#666; margin:.25rem 0 0 2rem; }
.ChatScreen { flex:1; display:none; flex-direction:column; background:#efeae2; }
.App.show-chat .ChatScreen { display:flex; }
.ChatHeader { background:var(--header-bg); padding:.5rem; display:flex; align-items:center; gap:.5rem; border-bottom:1px solid #ddd; cursor:pointer; }
.ChatHeader h3 { margin:0; flex:1; font-size:1rem; }
.BackButton, .SearchButton { font-size:1.5rem; padding:.25rem; background:none; border:none; cursor:pointer; }
.Messages { flex:1; overflow-y:auto; padding:1rem; display:flex; flex-direction:column; gap:.5rem; }
.Message { background:white; padding:.5rem 1rem; border-radius:.5rem; max-width:70%; word-break:break-word; margin:.5rem auto; position:relative; }
.Message .reactions { display:flex; gap:.25rem; margin-top:.25rem; }
.Message .reactions button { background:#f5f5f5; border:none; border-radius:.25rem; padding:0 .5rem; cursor:pointer; }
.AddReactionBtn { font-size:.9rem; background:none; border:none; cursor:pointer; color:var(--whatsapp-green); }
.ReactionInput { width:2rem; padding:.1rem; font-size:1rem; }
.Timestamp { font-size:.75rem; color:#666; margin-top:.25rem; text-align:right; }
.SendBar { display:flex; gap:.5rem; padding:1rem; background:white; border-top:1px solid #ddd; flex-direction:column; }
.ReplyPreview { background:#f5f5f5; 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; }
.ContextMenu { position:fixed; background:white; border:1px solid #ddd; border-radius:.25rem; box-shadow:0 2px 8px rgba(0,0,0,0.1); z-index:1000; min-width:140px; }
.ContextMenuItem { padding:.5rem 1rem; cursor:pointer; }
.ContextMenuItem:hover { background:#f5f5f5; }
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .SettingsModal, .CrossReplyModal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
background: white; padding: 2rem; border-radius: .5rem; box-shadow: 0 0 1rem rgba(0,0,0,0.1);
max-height: 80vh; overflow-y: auto; width: 90%; max-width: 400px; z-index: 1001;
}
.SearchModal input, .AppSettingsModal textarea, .CreateModal input, .SettingsModal input {
width: 100%; margin: .5rem 0; padding: .5rem;
border: 1px solid #ddd; border-radius: .25rem;
}
.SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; }
.SearchResult:hover { background: #f9f9f9; }
.ReplyIndicator {
border-left: 3px solid var(--whatsapp-green); padding-left: .5rem; margin-bottom: .5rem;
color: #666; font-size: .9em; cursor: pointer;
}
@media (max-width:768px) {
.ChatList { width: 100%; }
.ChatScreen { width: 100%; display: none; }
.App.show-chat .ChatList { display: none; }
.App.show-chat .ChatScreen { display: flex; }
}
</style>
</head>
<body>
<script type="module">
import { h, render, createContext } from 'https://esm.sh/preact';
import { useState, useEffect, useCallback, useRef, useContext } from 'https://esm.sh/preact/hooks';
import htm from 'https://esm.sh/htm';
const html = htm.bind(h), AppContext = createContext();
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 linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'<a href="$1" target="_blank">$1</a>');
const getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id));
const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } });
function App() {
const [state,setState] = useState({
notebooks: [], encrypted: {}, messages: {},
selectedNotebook: null, scrollToMessage: null,
showSettings: false, showAppSettings: false,
createModal: false, dateTimeModal: null,
crossReplyModal: false, crossReplySource: null,
contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 },
searchModal: { visible: false, global: false, query: '' },
editingMessage: null, replyingTo: null, reactionInputFor: null,
});
const inputRef = useRef();
// Load & decrypt
useEffect(() => {
const raw=JSON.parse(localStorage.getItem('notebooks')) || [],
enc={}, msgs={};
(async () => {
for (const notebook of raw) {
const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)) || [];
enc[notebook.id]=arr;
const aes = await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']),
rawKey = await crypto.subtle.exportKey('raw', aes),
plain = [];
for (const e of arr) {
const salt = b64ToBuf(e.salt),
iv = b64ToBuf(e.iv),
key = await deriveMsgKey(rawKey, salt),
ct = b64ToBuf(e.ciphertext),
dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
plain.push({ ...e, text: new TextDecoder().decode(dec) });
}
msgs[notebook.id] = plain;
}
setState(s => ({ ...s, notebooks: raw, encrypted: enc, messages: msgs }));
})();
}, []);
// Persist notebooks meta
useEffect(() => localStorage.setItem('notebooks', JSON.stringify(state.notebooks)), [state.notebooks]);
// Persist encrypted store
useEffect(() => {
for (const id in state.encrypted) {
localStorage.setItem(`notebook-${id}`, JSON.stringify(state.encrypted[id]));
}
}, [state.encrypted]);
// Close context on click-away
useEffect(() => {
const handler = event => {
if (state.contextMenu.visible) {
const menu = document.querySelector('.ContextMenu');
if (menu && !menu.contains(event.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' ? crypto.randomUUID() : prompt('Remote ID:'));
if (!id) return;
const now=Date.now(), aes=await genAESKey(), ed=await genEd25519();
const aesB64=await exportJWK(aes), privB64=await exportJWK(ed.privateKey), pubB64=await exportJWK(ed.publicKey);
const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
const notebook = {
id, name: `Notebook ${now}`,
emoji: randomEmoji(), color: randomColor(),
sourceType: type, description: '', parseMode: 'plaintext',
nextMessageId: 1, created: now,
aesKeyB64: aesB64, edPrivB64: privB64, edPubB64: pubB64,
};
setState(s => ({ ...s,
notebooks: [ ...s.notebooks, notebook ],
encrypted: { ...s.encrypted, [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]);
// Persist (encrypt & sync)
const persistMessages = useCallback(async(nbId, plainArr)=>{
const notebook = getNotebook(state.notebooks, nbId);
if (!notebook) return;
const aes=await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']),
rawKey=await crypto.subtle.exportKey('raw',aes),
encArr=[];
for(const m of plainArr){
const salt=randBytes(), iv=randBytes(12),
key=await deriveMsgKey(rawKey,salt),
ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.text));
encArr.push({ id:m.id, salt:bufToB64(salt), iv:bufToB64(iv),
ciphertext:bufToB64(ct), timestamp:m.timestamp,
edited:m.edited, replyTo:m.replyTo, reactions:m.reactions
});
}
setState(s => ({ ...s,
encrypted: { ...s.encrypted, [nbId]: encArr },
messages: { ...s.messages, [nbId]: plainArr },
}));
// 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(idx => setState(s => ({ ...s, reactionInputFor: idx })), []);
const confirmReaction = useCallback(async(idx,emoji)=>{
if(!emoji) return setState(s=>({...s,reactionInputFor:null}));
const nbId=state.selectedNotebook, arr=state.messages[nbId]||[];
const newArr=arr.map((m,i)=>i===idx?{...m,reactions:m.reactions.includes(emoji)?m.reactions:[...m.reactions,emoji]}:m);
await persistMessages(nbId,newArr);
setState(s=>({...s,reactionInputFor:null}));
},[state.selectedNotebook, state.messages, persistMessages]);
const removeReaction = useCallback(async (idx,emoji) => {
const nbId = state.selectedNotebook;
const arr = state.messages[nbId] || [];
const newArr = arr.map((m,i) => i===idx ? { ...m, reactions: m.reactions.filter(r=>r!==emoji) } : m);
await persistMessages(nbId, newArr);
}, [state.selectedNotebook, state.messages, persistMessages]);
// Editing effect: prefill textarea when entering edit mode
useEffect(() => {
if (state.editingMessage!=null && inputRef.current) {
const message = state.messages[state.selectedNotebook]?.[state.editingMessage];
if (message) {
inputRef.current.value = message.text;
}
//console.log(state, message);
}
}, [state.editingMessage, state.selectedNotebook, state.messages]);
const sendMessage = useCallback(async () => {
const nbId = state.selectedNotebook;
if (!nbId) return;
const text = inputRef.current.value.trim();
if (!text) return;
const arr = state.messages[nbId] || [];
const notebook = getNotebook(state.notebooks, nbId);
//console.log(state);
let message = arr[state.editingMessage];
if (!message) {
message = {
id: notebook.nextMessageId,
timestamp: Date.now(),
edited: state.editingMessage!=null,
replyTo: state.replyingTo,
reactions: [],
};
}
message = { ...message, text };
inputRef.current.value = '';
// update nextMessageId if new
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
: notebook
) }));
const newArr = (state.editingMessage!=null
? arr.map((msg, i) => (i===state.editingMessage ? message : msg))
: [...arr, message]
);
// reset editing & replying
setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
await persistMessages(nbId, newArr);
}, [state.selectedNotebook, state.editingMessage, state.replyingTo, state.messages, state.notebooks]);
return html`
<${AppContext.Provider} value=${{
state, setState, createNotebook,
sendMessage, persistMessages,
addReaction, confirmReaction, removeReaction,
}}>
<div class="App ${state.selectedNotebook ? 'show-chat' : ''}">
<${ChatList} />
<${ChatScreen} inputRef=${inputRef} />
${state.createModal && html`<${CreateModal} />`}
${state.crossReplyModal && html`<${CrossReplyModal} />`}
${state.showSettings && html`<${SettingsModal} />`}
${state.showAppSettings && html`<${AppSettingsModal} />`}
${state.contextMenu.visible && html`<${ContextMenu} />`}
${state.dateTimeModal!==null && html`<${DateTimeModal} />`}
${state.searchModal.visible && html`<${SearchModal} />`}
</div>
<//>
`;
}
function ChatList() {
const {state,setState} = useContext(AppContext);
const sortNotebook = (notebook) => Math.max(notebook.created, ...(state.messages[notebook.id] || []).map(message => message.timestamp));
return html`
<div class="ChatList">
<div class="ChatList-header">
<button onClick=${() => setState(s => ({ ...s, createModal: true }))}></button>
<button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button>
<button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button>
</div>
${state.notebooks.sort((a,b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html`
<button class="NotebookButton" key=${notebook.id}
onClick=${()=>setState(s=>({...s,selectedNotebook:notebook.id}))}>
<div class="NotebookTitle">
<div class="NotebookEmoji" style=${{background:notebook.color}}>${notebook.emoji}</div>
<h4 class="NotebookName">${notebook.name}</h4>
</div>
<div class="NotebookDescription">${notebook.description || 'No description'}</div>
<div class="NotebookPreview">
${(() => {
const arr = state.messages[notebook.id] || [];
return arr.length ? arr[arr.length-1].text : 'No messages';
})()}
</div>
</button>
`)}
</div>
`;
}
function ChatScreen({inputRef}) {
const { state, setState, sendMessage } = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook);
let messages = state.messages[notebook?.id] || [];
messages = [...messages].sort((a,b) => (a.timestamp - b.timestamp));
// Scroll on request
useEffect(()=>{
if (state.scrollToMessage!=null) {
document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setState(s => ({ ...s, scrollToMessage:null }));
}
}, [state.scrollToMessage, state.selectedNotebook]);
if (!notebook) return null;
return html`
<div class="ChatScreen">
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
<button class="BackButton"
onClick=${e => {
e.stopPropagation();
setState(s => ({ ...s, selectedNotebook: null }));
}}>
</button>
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
<h3>${notebook.name}</h3>
<button class="SearchButton"
onClick=${e => {
e.stopPropagation();
setState(s => ({ ...s, searchModal: { visible: true, global: false, query: '' }}));
}}>
🔍
</button>
</div>
<div class="Messages">
${messages.map((m,i) => html`
<${Message} m=${m} i=${i} />
`)}
</div>
<div class="SendBar">
${state.replyingTo && html`
<div class="ReplyPreview">
<span>Replying to: ${
(state.messages[state.replyingTo.notebookId] || []).find(x => x.id===state.replyingTo.id)?.text || ''
}</span>
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
</div>`}
<textarea ref=${inputRef} class="EditArea"
onKeyPress=${e => e.key==='Enter' && !e.shiftKey && sendMessage()}/>
<button onClick=${sendMessage}>${state.editingMessage!=null?'Save':'Send'}</button>
</div>
</div>
`;
}
function Message({m,i}) {
const {
state, setState,
addReaction, confirmReaction, removeReaction
} = useContext(AppContext);
return html`
<div class="Message" data-msg-id=${m.id}
onContextMenu=${event => {
event.preventDefault();
setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: event.clientX, y: event.clientY } }));
}}
onTouchStart=${event => {
event.preventDefault();
const target = event.touches[0];
setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: target.clientX, y: target.clientY } }));
}}>
${m.replyTo&&html`
<div class="ReplyIndicator"
onClick=${()=>setState(s=>({
...state,
selectedNotebook: m.replyTo.notebookId,
scrollToMessage: m.replyTo.id,
}))}>
Reply to "${(state.messages[m.replyTo.notebookId] || []).find(x => x.id===m.replyTo.id)?.text || ''}"
</div>`}
<div dangerouslySetInnerHTML=${{__html:linkify(m.text)}}/>
<div class="reactions">
${m.reactions.map(r => html`
<button onClick=${() => removeReaction(i,r)}>${r}</button>
`)}
${state.reactionInputFor===i
? html`<input class="ReactionInput" maxlength="2" autofocus
onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(i,e.target.value), e.target.value='')} />`
: html`<button class="AddReactionBtn" onClick=${()=>addReaction(i)}></button>`
}
</div>
<div class="Timestamp">${new Date(m.timestamp).toLocaleString()}${m.edited ? ' (edited)' : ''}</div>
</div>
`
}
function CreateModal() {
const {createNotebook, setState} = useContext(AppContext);
createNotebook('local');
return '';
// return html`
// <div class="CreateModal">
// <h3>Create Notebook</h3>
// <button onClick=${() => createNotebook('local')}>Local Notebook</button>
// <button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
// <button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
// </div>
// `;
}
function CrossReplyModal() {
const {state, setState} = useContext(AppContext);
return html`
<div class="CrossReplyModal">
<h3>Reply in Another Notebook</h3>
${state.notebooks.filter(notebook => notebook.id!==state.crossReplySource.notebook).map(notebook => html`
<button onClick=${() => setState(s => ({ ...s,
selectedNotebook: notebook.id,
replyingTo: { notebookId: s.crossReplySource.notebook, id: s.crossReplySource.id },
crossReplyModal: false,
}))}>${notebook.emoji} ${notebook.name}</button>
`)}
<button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>Cancel</button>
</div>
`;
}
function ContextMenu() {
const {state, setState, persistMessages} = useContext(AppContext);
const idx = state.contextMenu.messageIndex;
const nbId = state.selectedNotebook;
const arr = state.messages[nbId] || [];
const msg = arr[idx];
const handle = action => {
let newArr;
switch(action){
case 'reply':
setState(s => ({ ...s, replyingTo: { notebookId: nbId, id: msg.id }, ...closedContextMenu(s) }));
return;
case 'cross-reply':
setState(s => ({ ...s, ...closedContextMenu(s), crossReplyModal: true, crossReplySource: { notebook: nbId, id: msg.id }}));
return;
case 'edit':
setState(s => ({ ...s, editingMessage: idx, ...closedContextMenu(s) }));
return;
case 'datetime':
setState(s => ({ ...s, dateTimeModal: idx, ...closedContextMenu(s) }));
return;
case 'delete':
newArr=arr.filter((_,i)=>i!==idx);
persistMessages(nbId, newArr);
setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, ...closedContextMenu(s) }));
return;
}
};
return html`
<div class="ContextMenu" style=${`left: ${state.contextMenu.x}px; top: ${state.contextMenu.y}px;`}>
<div class="ContextMenuItem" onClick=${() => handle('reply')}>Reply</div>
<div class="ContextMenuItem" onClick=${() => handle('cross-reply')}>Reply in Another Notebook</div>
<div class="ContextMenuItem" onClick=${() => handle('edit')}>Edit</div>
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>Set Date/Time</div>
<div class="ContextMenuItem" onClick=${() => handle('delete')}>Delete</div>
</div>
`;
}
function DateTimeModal() {
const {state, setState, persistMessages} = useContext(AppContext);
const idx=state.dateTimeModal, nbId=state.selectedNotebook;
const arr=state.messages[nbId]||[], msg=arr[idx];
const [dt,setDt] = useState('');
useEffect(() => (msg && setDt(new Date(msg.timestamp).toISOString().slice(0,16))), [msg]);
const save=()=>{
const timestamp = new Date(dt).getTime();
if (!isNaN(timestamp)) {
const newArr = arr.map((m,i) => i===idx ? { ...m, timestamp } : m);
persistMessages(nbId, newArr);
setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, dateTimeModal: null }));
}
};
return html`
<div class="DateTimeModal">
<h3>Set Date/Time</h3>
<input type="datetime-local" value=${dt} onChange=${e => setDt(e.target.value)}/>
<button onClick=${save}>Save</button>
<button onClick=${() => setState(s => ({ ...s, dateTimeModal: null }))}>Cancel</button>
</div>
`;
}
function SettingsModal() {
const {state, setState} = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook);
const [form, setForm] = useState({ ...notebook });
const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showSettings: false }));
const del = () => {
if (confirm('Delete?')) {
if (notebook.sourceType==='local') {
localStorage.removeItem(`notebook-${notebook.id}`);
}
setState(s => ({ ...s,
notebooks: s.notebooks.filter(n => n.id!==notebook.id),
messages: { ...s.messages, [notebook.id]: undefined },
encrypted: { ...s.encrypted, [notebook.id]: undefined },
selectedNotebook: null, showSettings: false,
}));
}
};
return html`
<div class="SettingsModal">
<h3>Settings</h3>
<label>Name: <input value=${form.name} onChange=${e => setForm(f => ({ ...f, name: e.target.value }))}/></label><br/>
<label>Emoji: <input value=${form.emoji} maxLength="2" onChange=${e => setForm(f => ({ ...f, emoji: e.target.value }))}/></label><br/>
<label>Color: <input type="color" value=${form.color} onChange=${e => setForm(f => ({ ...f, color: e.target.value }))}/></label><br/>
<label>Description: <input value=${form.description} onChange=${e => setForm(f => ({ ...f, description: e.target.value }))}/></label><br/>
<label>Parse Mode: <select value=${form.parseMode} onChange=${e => setForm(f => ({ ...f, parseMode: e.target.value }))}>
<option value="plaintext">Plaintext</option>
</select></label><br/><br/>
<button onClick=${save}>Save</button>
<button onClick=${del} style="color:red">Delete</button>
<button onClick=${() => setState(s => ({ ...s, showSettings: false }))}>Close</button>
</div>
`;
}
function SearchModal() {
const {state, setState} = 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.selectedNotebook] || []).map(message => ({ ...message, notebook: getNotebook(state.notebooks, state.selectedNotebook) }))
).filter(message => message.text.toLowerCase().includes(query.toLowerCase()));
const select = (nbId, mId) => setState(s => ({ ...s, selectedNotebook: nbId, searchModal: { ...s.searchModal, visible: false }, scrollToMessage: mId }));
return html`
<div class="SearchModal">
<h3>${global ? 'Global' : 'Notebook'} Search</h3>
<input placeholder="Search..." value=${query} onInput=${e => setState(s => ({ ...s, searchModal: { ...s.searchModal, query: e.target.value }}))}/>
${results.map(result => html`
<div class="SearchResult" onClick=${() => select(result.notebook.id, result.id)}>
${global && html`<div class="NotebookTitle">
<div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div>
<strong>${result.notebook.name}</strong>
</div>`}
<div>${result.text}</div><em>${new Date(result.timestamp).toLocaleString()}</em>
</div>
`)}
<button onClick=${() => setState(s => ({ ...s, searchModal: { ...s.searchModal, visible: false }}))}>Close</button>
</div>
`;
}
function AppSettingsModal() {
const {state,setState} = useContext(AppContext);
const exportData = () => JSON.stringify({ notebooks: state.notebooks, encrypted: state.encrypted }, null, 2);
const [txt,setTxt] = useState('');
const doImport = () => {
try {
const obj = JSON.parse(txt);
if (obj.notebooks && obj.encrypted) {
localStorage.setItem('notebooks', JSON.stringify(obj.notebooks));
Object.entries(obj.encrypted).forEach(([id,arr]) => localStorage.setItem(`notebook-${id}`, JSON.stringify(arr)));
window.location.reload();
} else {
alert('Invalid format');
}
} catch (err) {
console.error(err);
alert('Invalid JSON');
}
};
return html`
<div class="AppSettingsModal">
<h3>App Settings</h3>
<h4>Export Data</h4><textarea readonly rows="8">${exportData()}</textarea>
<h4>Import Data</h4><textarea rows="6" placeholder="Paste JSON" onInput=${e => setTxt(e.target.value)}/>
<button onClick=${doImport}>Import</button>
<button onClick=${() => setState(s => ({ ...s, showAppSettings:false }))}>Close</button>
</div>
`;
}
render(html`<${App}/>`, document.body);
</script>
</body>
</html>