Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a07ac5e11 | ||
|
|
78fd07bd0d | ||
|
|
1b45b477d6 | ||
|
|
32dbda5c78 | ||
|
|
da6c30fac4 | ||
|
|
16ba5de7c5 |
No files matched your search
+9
-7
@@ -174,7 +174,9 @@ const TABS = [['home', 'Home'], ['family', 'Family'], ['files', 'Files'], ['musi
|
|||||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', recipes: 'chef', contacts: 'people', chat: 'chat', family: 'people', notes: 'doc', chores: 'check', store: 'store', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||||
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
|
const sgInChat = () => allowed('chat') && allowed('signal'); // with both, Signal conversations are in the Chat list and there is one entry, not two
|
||||||
|
const chatUnread = () => (ch.unread || 0) + (sgInChat() ? (sg.unread || 0) : 0);
|
||||||
|
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0]) || (t[0] === 'signal' && sgInChat())) continue; let id = t[0], label = t[1];
|
||||||
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
||||||
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
||||||
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
||||||
@@ -202,13 +204,13 @@ function renderNav(active = activeTab) {
|
|||||||
if (typeof sgDockBadge === 'function') sgDockBadge();
|
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||||
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
||||||
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||||
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(ch.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
const tab = ([id, label]) => `<a href="#/${id}" title="${esc(label)}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'chat' ? badge(chatUnread()) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
||||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||||
const mine = navTabs();
|
const mine = navTabs();
|
||||||
if (phone.matches) {
|
if (phone.matches) {
|
||||||
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
||||||
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
||||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(ch.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : rest.some(t => t[0] === 'chat') ? badge(chatUnread()) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
||||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
||||||
} else {
|
} else {
|
||||||
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||||
@@ -219,7 +221,7 @@ phone.addEventListener('change', () => renderNav());
|
|||||||
|
|
||||||
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
||||||
function openLauncher() {
|
function openLauncher() {
|
||||||
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'chat' ? chatUnread() : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
||||||
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
||||||
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
||||||
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
||||||
@@ -255,7 +257,7 @@ function drawSubnav(name) {
|
|||||||
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
||||||
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
||||||
}
|
}
|
||||||
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
const navIdOf = name => name === 'signal' && sgInChat() ? 'chat' : name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||||
let curRoute = '';
|
let curRoute = '';
|
||||||
|
|
||||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: ME && ME.simple && typeof viewSimple === 'function' ? viewSimple : viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? (allowed('store') ? location.replace('#/store/audiobooks') : viewFreeBooks()) : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: () => viewRadio(), recipes: args => viewRecipes(args), store: args => viewStore(args), chat: args => viewChat(args), notes: args => viewNotes(args), family: () => viewFamily(), chores: () => viewChores(), contacts: args => viewContacts(args), bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||||
@@ -328,7 +330,7 @@ function homeApps() {
|
|||||||
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
||||||
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
|
chores: () => tile('#/chores', '#2f9e6e', 'check', 'Chores', 'Jobs & points'),
|
||||||
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
|
notes: () => tile('#/notes', '#e5b800', 'doc', 'Notes', 'Notes & shared lists'),
|
||||||
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', ch.unread ? ch.unread + ' unread' : 'Family messages'),
|
chat: () => tile('#/chat', '#2aa6a0', 'chat', 'Chat', chatUnread() ? chatUnread() + ' unread' : sgInChat() ? 'Family and Signal' : 'Family messages'),
|
||||||
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
|
store: () => tile('#/store', '#d9822b', 'store', 'Store', 'Books, audiobooks, radio & more'),
|
||||||
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
|
recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'),
|
||||||
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
|
contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'),
|
||||||
@@ -340,7 +342,7 @@ function homeApps() {
|
|||||||
};
|
};
|
||||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||||
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
const HOME_GROUPS = [['Work', ['planner', 'notes', 'chores', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', 'chat', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video', 'store']]];
|
||||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id) && !(id === 'signal' && sgInChat()))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewHome() {
|
function viewHome() {
|
||||||
|
|||||||
@@ -38,7 +38,15 @@ async function chLoadSignal() {
|
|||||||
ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return;
|
ch.sig = []; if (typeof allowed !== 'function' || !allowed('signal') || typeof sgApi !== 'function') return;
|
||||||
try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; }
|
try { const r = await sgApi('/convs'); ch.sig = (r.convs || []).map(c => ({ ...c, source: 'signal', name: c.name || c.id })); } catch { ch.sig = []; }
|
||||||
}
|
}
|
||||||
const chSigRow = c => `<a class="row sgrow" data-sig="1" href="#/signal/${encodeURIComponent(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
|
const chSigRow = c => `<a class="row sgrow ${c.id === ch.curSig ? 'on' : ''}" data-sig="1" href="${sgHref(c.id)}"><span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name))}</span>
|
||||||
<span class="grow"><b>${esc(c.name)} <small class="srcbadge">Signal</small></b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`;
|
<span class="grow"><b>${esc(c.name)} <small class="srcbadge">Signal</small></b><small>${esc(c.preview || '')}</small></span><span class="sgside"><small>${c.ts ? sgTime(c.ts) : ''}</small>${c.unread ? `<i class="nbadge" style="position:static">${c.unread}</i>` : ''}</span></a>`;
|
||||||
/** Signal changed (a message came in): redraw the Chat list if it is showing. */
|
/** Signal changed (a message came in): redraw the Chat list if it is showing. */
|
||||||
function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); }
|
function chSigChanged() { if (typeof ch !== 'undefined' && ch.root && document.contains(ch.root) && location.hash.startsWith('#/chat')) chLoadSignal().then(() => { if (chq('#chlist')) chDrawList(); }); }
|
||||||
|
|
||||||
|
/** A Signal conversation shown in Chat's own conversation pane (so choosing one in the combined list never leaves Chat). */
|
||||||
|
async function chShowSignal() {
|
||||||
|
const box = chq('#chm'); if (!box) return;
|
||||||
|
if (!box.querySelector('#sgm')) { box.innerHTML = '<div id="sgm" class="sgin"></div>'; sg.reply = null; sg.tray = []; }
|
||||||
|
sg.root = ch.root; sg.dock = false; sg.inChat = true; sg.cur = ch.curSig;
|
||||||
|
await sgDrawThread(true);
|
||||||
|
}
|
||||||
+7
-5
@@ -34,10 +34,10 @@ function chEvent(ev) {
|
|||||||
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
|
clearTimeout(chEvT); chEvT = setTimeout(chRefresh, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewChat(args = []) { ch.cur = args[0] ? decodeURIComponent(args[0]) : null; return chMount($('#view')); }
|
function viewChat(args = []) { const id = args[0] ? decodeURIComponent(args[0]) : null; ch.curSig = id && id.startsWith('sg:') ? id.slice(3) : null; ch.cur = ch.curSig ? null : id; if (!ch.curSig && typeof sg === 'object') sg.inChat = false; return chMount($('#view')); }
|
||||||
async function chMount(root) {
|
async function chMount(root) {
|
||||||
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
ch.root = root; root.innerHTML = '<div class="wrap sgwrap"><h1>Chat</h1><p class="empty">Loading…</p></div>';
|
||||||
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
root.innerHTML = `<div class="wrap sgwrap"><div class="sg ${ch.cur || ch.curSig ? 'open' : ''}"><div class="sgl"><div class="sghead"><h1>Chat</h1><button class="ibtn" id="chset" aria-label="Chat settings">${icon('cog')}</button><button class="btn ghost" id="chnew">${icon('edit')}New</button></div>
|
||||||
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
<input type="search" id="chq" placeholder="Search chats and messages" autocomplete="off"><div class="list sglist" id="chlist"></div></div><div class="sgm" id="chm"></div></div></div>`;
|
||||||
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
chq('#chnew').onclick = chNewChat; chq('#chset').onclick = chSettings; let t; chq('#chq').oninput = () => { chDrawList(); clearTimeout(t); t = setTimeout(chSearchMessages, 350); };
|
||||||
await chRefresh(true);
|
await chRefresh(true);
|
||||||
@@ -46,7 +46,7 @@ async function chRefresh(first) {
|
|||||||
try {
|
try {
|
||||||
const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
|
const [r] = await Promise.all([chApi('/convs'), typeof chLoadSignal === 'function' ? chLoadSignal() : null]); ch.convs = r.convs; ch.unread = r.unread; renderNav(activeTab);
|
||||||
if (!chq('#chlist')) return; chDrawList();
|
if (!chq('#chlist')) return; chDrawList();
|
||||||
if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
if (ch.curSig && typeof chShowSignal === 'function') await chShowSignal(); else if (ch.cur) await chDrawThread(first); else chq('#chm').innerHTML = '<p class="empty">Choose a chat, or start a new one.</p>';
|
||||||
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
} catch (e) { const l = chq('#chlist'); if (l) l.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
async function chSearchMessages() {
|
async function chSearchMessages() {
|
||||||
@@ -189,9 +189,10 @@ async function chPeople() { if (!ch.people) ch.people = await chApi('/people');
|
|||||||
async function chNewChat() {
|
async function chNewChat() {
|
||||||
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
|
let people = []; try { people = await chPeople(); } catch (e) { return toast(e.message); }
|
||||||
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
|
const el = openSheet('New chat', 'Pick someone in the family', `<div class="list">${people.map(p => `<button class="row" data-p="${esc(p.id)}"><span class="sgav" style="background:${sgColor(p.id)}">${esc(sgInitial(p.name))}</span><span class="grow"><b>${esc(p.name)}</b></span></button>`).join('') || '<p class="empty">There is nobody else with Chat yet. An admin can turn it on for people in Settings.</p>'}</div>
|
||||||
${people.length > 1 ? '<div class="bar"><button class="btn ghost" id="chg" style="width:100%;justify-content:center">New group…</button></div>' : ''}`);
|
<div class="bar" style="flex-wrap:wrap">${people.length > 1 ? '<button class="btn ghost" id="chg" style="flex:1;justify-content:center">New group…</button>' : ''}${typeof sgNewChat === 'function' && typeof sgInChat === 'function' && sgInChat() ? '<button class="btn ghost" id="chsgn" style="flex:1;justify-content:center">New Signal chat…</button>' : ''}</div>`);
|
||||||
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
|
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { try { const r = await chPost('/new', { type: 'direct', with: b.dataset.p }); closeSheet(); location.hash = '#/chat/' + encodeURIComponent(r.conv.id); } catch (e) { toast(e.message); } });
|
||||||
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
|
const g = el.querySelector('#chg'); if (g) g.onclick = () => { closeSheet(); chNewGroup(people); };
|
||||||
|
const sn = el.querySelector('#chsgn'); if (sn) sn.onclick = () => { closeSheet(); sg.inChat = true; sg.root = ch.root; sgNewChat(); };
|
||||||
}
|
}
|
||||||
function chNewGroup(people) {
|
function chNewGroup(people) {
|
||||||
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
|
const el = openSheet('New group', '', `<form id="chgf"><label for="chgn">Group name</label><input type="text" id="chgn" maxlength="60" required autocomplete="off" placeholder="Family"><label>Who is in it?</label>
|
||||||
@@ -351,10 +352,11 @@ async function chSettings() {
|
|||||||
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
let pr, an = null; try { [pr, an] = await Promise.all([chApi('/prefs'), chApi('/announcement')]); } catch (e) { return toast(e.message); }
|
||||||
const q = pr.quiet, admin = ME && ME.role === 'admin';
|
const q = pr.quiet, admin = ME && ME.role === 'admin';
|
||||||
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
|
const el = openSheet('Chat settings', '', `<form id="chsf"><label class="row" style="cursor:pointer"><span class="grow"><b>Quiet hours</b><small style="white-space:normal">No phone alerts from Chat in these hours. Messages still show up in the app.</small></span><input type="checkbox" id="chqo"${q.on ? ' checked' : ''}></label>
|
||||||
|
${typeof allowed === 'function' && allowed('signal') ? `<a class="row" href="#/signal" id="chsig" style="background:var(--panel2);border-radius:12px;margin:10px 0"><span class="grow"><b>Signal</b><small style="white-space:normal">Link or unlink your Signal account, and start a new Signal conversation. Your Signal chats are in this list.</small></span><span style="color:var(--accent)">Open ›</span></a>` : ''}
|
||||||
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
|
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1"><label for="chqf">From</label><input type="time" id="chqf" value="${esc(q.from)}"></div><div style="flex:1"><label for="chqt">To</label><input type="time" id="chqt" value="${esc(q.to)}"></div></div>
|
||||||
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
|
${admin ? `<h2>Family announcement</h2><p class="sub" style="white-space:normal">Shown at the top of everyone's Home. Leave it empty and save to take it down.</p><textarea id="chan" rows="2" maxlength="300" placeholder="Dinner at 6">${esc(an ? an.text : '')}</textarea>` : ''}<div class="err" id="chse"></div>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="chsx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||||
el.querySelector('#chsx').onclick = closeSheet;
|
el.querySelector('#chsx').onclick = closeSheet; const sgl = el.querySelector('#chsig'); if (sgl) sgl.onclick = () => closeSheet();
|
||||||
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
|
el.querySelector('#chsf').onsubmit = async e => { e.preventDefault(); try { await chPost('/prefs', { quiet: { on: el.querySelector('#chqo').checked, from: el.querySelector('#chqf').value, to: el.querySelector('#chqt').value } }); if (admin) await chPost('/announcement', { text: el.querySelector('#chan').value }); closeSheet(); toast('Saved'); } catch (x) { el.querySelector('#chse').textContent = x.message; } };
|
||||||
}
|
}
|
||||||
/** Home: the family announcement, if there is one (each person can dismiss it). */
|
/** Home: the family announcement, if there is one (each person can dismiss it). */
|
||||||
|
|||||||
+13
-2
@@ -6,13 +6,23 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
|
|||||||
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
|
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
|
||||||
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
|
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
|
// On a phone the editor normally uses OnlyOffice's phone layout, which on some OnlyOffice editions only lets you read. "Full editor" asks for the desktop layout instead (scaled to fit the screen),
|
||||||
|
// and "Edit elsewhere" downloads a copy to edit in another app and uploads it back over this file.
|
||||||
|
const phone = innerWidth < 768, full = phone && store.get('oo_layout', 'auto') === 'full';
|
||||||
|
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b>${phone ? `<button class="btn ghost" id="edfull" title="${full ? 'Back to the phone layout' : 'Use the full desktop editor (small, but it can edit)'}">${full ? 'Phone view' : 'Full editor'}</button><button class="ibtn" id="edmore" aria-label="Edit elsewhere" title="Edit in another app">${icon('more')}</button>` : ''}</div>
|
||||||
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
|
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
|
||||||
|
if (phone) {
|
||||||
|
$('#edfull').onclick = () => { store.set('oo_layout', full ? 'auto' : 'full'); viewEdit([repo, ...rawParts], opts); };
|
||||||
|
$('#edmore').onclick = () => actionSheet(name, 'Edit in another app', [
|
||||||
|
{ label: 'Download a copy', icon: 'dl', run: () => downloadItem(repo, path) },
|
||||||
|
{ label: 'Replace with an edited copy…', icon: 'up', run: () => { const f = document.createElement('input'); f.type = 'file'; f.accept = '.' + name.split('.').pop(); f.onchange = async () => { const file = f.files[0]; if (!file) return; try { const dir = path.slice(0, path.lastIndexOf('/')) || '/', link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData(); fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', file, name); const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd }); if (!up.ok) throw new Error('Could not replace it (' + up.status + ')'); toast('Replaced with your edited copy'); viewEdit([repo, ...rawParts], opts); } catch (e) { toast(e.message); } }; f.click(); } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
if (!sf.token()) return (location.hash = '#/files');
|
if (!sf.token()) return (location.hash = '#/files');
|
||||||
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
|
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
|
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
|
||||||
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
|
body: JSON.stringify({ repo, path, mobile: phone && !full }) });
|
||||||
const conf = await r.json();
|
const conf = await r.json();
|
||||||
if (!r.ok) return fail(conf.error || 'Could not open document');
|
if (!r.ok) return fail(conf.error || 'Could not open document');
|
||||||
if (!window.DocsAPI) await new Promise((ok, no) => {
|
if (!window.DocsAPI) await new Promise((ok, no) => {
|
||||||
@@ -22,6 +32,7 @@ async function viewEdit([repo, ...rawParts], opts = {}) {
|
|||||||
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
|
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
|
||||||
$('#oomsg').remove();
|
$('#oomsg').remove();
|
||||||
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
|
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
|
||||||
|
if (full) { const wrap = $('.edwrap'), W = 1000, k = Math.min(1, wrap.clientWidth / W), box = $('#oo'); wrap.style.overflow = 'hidden'; box.style.cssText = `width:${W}px;height:${Math.round(wrap.clientHeight / k)}px;transform:scale(${k});transform-origin:0 0`; } // (the desktop editor is laid out wide, then shrunk to fit the phone)
|
||||||
editor = new DocsAPI.DocEditor('oo', conf);
|
editor = new DocsAPI.DocEditor('oo', conf);
|
||||||
} catch (e) { fail(e.message); }
|
} catch (e) { fail(e.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,16 +20,26 @@ function ofOpen(repo, path, name) {
|
|||||||
}
|
}
|
||||||
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
|
function ofIcon(name) { const k = OFFICE_KINDS[ofKind(name)]; return `<span class="ic ofic" style="background:${k.color}">${icon('doc')}<em>${esc(ext(name).slice(0, 4).toUpperCase())}</em></span>`; }
|
||||||
|
|
||||||
|
/** Ready-made starting points (files in public/templates): the person gets their own copy to fill in. */
|
||||||
|
const OFFICE_TEMPLATES = [
|
||||||
|
['letter.docx', 'Letter', 'A formal or friendly letter, laid out', 'docx'], ['resume.docx', 'Resume', 'Summary, experience, education and skills', 'docx'], ['meeting-notes.docx', 'Meeting notes', 'Agenda, notes and action items', 'docx'],
|
||||||
|
['family-newsletter.docx', 'Family newsletter', 'News, who did what and what is coming up', 'docx'], ['recipe-card.docx', 'Recipe card', 'Ingredients, steps and notes', 'docx'],
|
||||||
|
['monthly-budget.xlsx', 'Monthly budget', 'Planned against actual, with the math done', 'xlsx'], ['expense-tracker.xlsx', 'Expense tracker', 'One line per purchase, totals by category', 'xlsx'],
|
||||||
|
['chore-chart.xlsx', 'Chore chart', 'A week of chores with a count for each', 'xlsx'], ['meal-planner.xlsx', 'Weekly meal planner', 'Breakfast, lunch, dinner and a shopping list', 'xlsx'],
|
||||||
|
['simple-presentation.pptx', 'Simple presentation', 'Title, agenda, key points and a thank-you', 'pptx'],
|
||||||
|
];
|
||||||
function viewOffice(args = []) {
|
function viewOffice(args = []) {
|
||||||
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
||||||
const signed = !!sf.token();
|
const signed = !!sf.token();
|
||||||
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
|
$('#view').innerHTML = `<div class="wrap" id="ofwrap"><div class="ptop"><h1>Office</h1><span class="pspace"></span>${signed ? `<button class="btn ghost" id="ofup">${icon('up')}Upload</button><input type="file" id="ofupf" multiple hidden accept=".docx,.doc,.odt,.rtf,.xlsx,.xls,.ods,.csv,.pptx,.ppt,.odp">` : ''}<a class="btn ghost" href="#/files">${icon('files')}All files</a></div>
|
||||||
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
|
<div class="ocreate">${Object.entries(OFFICE_KINDS).map(([k, o]) => `<button class="octile" data-k="${k}" style="--c:${o.color}"><span class="ic" style="background:${o.color}">${icon('plus')}</span><span><b>New ${o.one.toLowerCase()}</b><small>${o.exts[0].toUpperCase()}</small></span></button>`).join('')}</div>
|
||||||
|
<details class="otplbox" id="otpl"><summary><b>Start from a template</b> <small>${OFFICE_TEMPLATES.length} ready-made documents, spreadsheets and slides</small></summary><div class="otpl">${OFFICE_TEMPLATES.map((t, i) => `<button type="button" class="octile otc" data-t="${i}" style="--c:${OFFICE_KINDS[ofKind('x.' + t[3])].color}">${ofIcon('x.' + t[3])}<span><b>${esc(t[1])}</b><small>${esc(t[2])}</small></span></button>`).join('')}</div></details>
|
||||||
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
|
${cfg.editor ? '' : '<p class="sub" style="margin:8px 0 0">Editing inside the app is switched off on this server, so documents open in Seafile instead.</p>'}
|
||||||
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
<p class="sub" id="olib" style="margin:12px 0 0">${signed ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
||||||
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
|
<div id="oflt" class="ofilters" hidden><input type="search" id="oq" placeholder="Search documents" autocomplete="off" aria-label="Search documents" value="${esc(ofState.q)}"><div class="sgrp" id="okinds" role="tablist" aria-label="Kind"></div><select id="osort" class="psel" aria-label="Sort"><option value="mtime">Newest first</option><option value="name">Name</option><option value="size">Largest first</option></select></div>
|
||||||
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
|
<div id="odocs"></div>${signed ? '' : '<div class="bar"><a class="btn" href="#/files">Sign in</a></div>'}</div>`;
|
||||||
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
|
$('#view').querySelectorAll('.octile').forEach(t => t.onclick = () => newOfficeDoc(OFFICE_KINDS[t.dataset.k].exts[0], OFFICE_KINDS[t.dataset.k].one.toLowerCase()));
|
||||||
|
$('#view').querySelectorAll('.otc').forEach(b => b.onclick = () => { const t = OFFICE_TEMPLATES[+b.dataset.t]; newOfficeDoc(t[3], t[1], t[0], t[1]); });
|
||||||
if (!signed) return;
|
if (!signed) return;
|
||||||
$('#ofup').onclick = () => $('#ofupf').click();
|
$('#ofup').onclick = () => $('#ofupf').click();
|
||||||
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
|
$('#ofupf').onchange = e => { const files = [...e.target.files]; e.target.value = ''; if (files.length) ofUpload(files.map(file => ({ file, rel: '' }))); };
|
||||||
|
|||||||
+11
-5
@@ -7,6 +7,8 @@ const sgTime = ts => { const d = new Date(ts), n = new Date(); return d.toDateSt
|
|||||||
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
const sgInitial = n => (String(n).replace(/^\+/, '').trim()[0] || '?').toUpperCase();
|
||||||
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
const sgColor = id => `hsl(${[...String(id)].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7)} 45% 42%)`;
|
||||||
const sgOnView = () => location.hash.startsWith('#/signal');
|
const sgOnView = () => location.hash.startsWith('#/signal');
|
||||||
|
/** Where a Signal conversation opens: inside Chat when the person has both (the Chat list shows Signal chats too), otherwise the Signal screen. */
|
||||||
|
const sgHref = id => typeof sgInChat === 'function' && sgInChat() ? '#/chat/' + encodeURIComponent('sg:' + id) : '#/signal/' + encodeURIComponent(id);
|
||||||
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
const sgMounted = () => !!sg.root && document.contains(sg.root);
|
||||||
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
async function sgStart() { // keep the unread count and live updates running whatever screen is open
|
||||||
if (!allowed('signal') || sg.es) return;
|
if (!allowed('signal') || sg.es) return;
|
||||||
@@ -38,7 +40,7 @@ function sgAlert(ev) { // a message from someone else arrived and you are not lo
|
|||||||
if (a.sound) sgChime();
|
if (a.sound) sgChime();
|
||||||
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
|
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
|
||||||
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
|
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
|
||||||
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView() || sg.inChat) location.hash = sgHref(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let sgEvT = null;
|
let sgEvT = null;
|
||||||
@@ -49,6 +51,9 @@ function sgEvent(ev) {
|
|||||||
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
|
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
|
||||||
}
|
}
|
||||||
if (ev.t === 'gone') sgGone(ev.conv, true);
|
if (ev.t === 'gone') sgGone(ev.conv, true);
|
||||||
|
if (sg.inChat) { // a Signal conversation is open inside Chat: refresh it and the combined list, never turn the Chat screen into the Signal screen
|
||||||
|
if (!sgMounted()) return; clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (sg.cur && ev.t !== 'status') sgDrawThread(false); if (typeof chSigChanged === 'function') chSigChanged(); }, 150); return;
|
||||||
|
}
|
||||||
if (!sgMounted()) return;
|
if (!sgMounted()) return;
|
||||||
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
|
||||||
}
|
}
|
||||||
@@ -57,7 +62,8 @@ function sgEvent(ev) {
|
|||||||
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
function viewSignal(args = []) { sg.cur = args[0] || null; sgDockHide(); return sgMount($('#view'), false); }
|
||||||
// ... and the same screens in a panel that floats over every other screen.
|
// ... and the same screens in a panel that floats over every other screen.
|
||||||
async function sgMount(root, dock) {
|
async function sgMount(root, dock) {
|
||||||
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : '<h1>Signal</h1>';
|
sg.inChat = false; // (a full Signal screen or the floating panel; Chat sets this itself when it shows a Signal conversation)
|
||||||
|
sg.root = root; sg.dock = dock; const cls = dock ? 'sgdockin' : 'wrap sgwrap', title = dock ? '' : `<h1>Signal</h1>${typeof sgInChat === 'function' && sgInChat() ? `<p class="sub" style="margin:0 0 8px"><a href="#/chat" style="color:var(--accent)">‹ Back to Chat</a> · your Signal chats are also listed there</p>` : ''}`;
|
||||||
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
root.innerHTML = `<div class="${cls}">${title}<p class="empty">Loading…</p></div>`;
|
||||||
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
let st; try { st = await sgApi('/status'); } catch (e) { root.innerHTML = `<div class="${cls}">${title}<p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
if (sg.root !== root) return; sg.st = st;
|
if (sg.root !== root) return; sg.st = st;
|
||||||
@@ -114,7 +120,7 @@ async function sgDrawThread(first) {
|
|||||||
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
if (sg.cur !== id || !sgq('#sgm')) return; sg.names = r.names || {};
|
||||||
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
const c = r.conv, old = box.querySelector('.sgmsgs'), stick = !old || old.scrollHeight - old.scrollTop - old.clientHeight < 80;
|
||||||
if (!box.querySelector('.sgbar')) {
|
if (!box.querySelector('.sgbar')) {
|
||||||
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="#/signal" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
box.innerHTML = `<div class="sgbar">${sg.dock ? `<button type="button" class="ibtn sgback" id="sgbk" aria-label="Back to chats">${icon('back')}</button>` : `<a class="ibtn sgback" href="${sg.inChat ? '#/chat' : '#/signal'}" aria-label="Back to chats">${icon('back')}</a>`}<span class="sgav" style="background:${sgColor(c.id)}">${c.type === 'group' ? icon('chat') : esc(sgInitial(c.name || c.id))}</span><span class="grow"><b id="sgtitle"></b><small id="sgsub"></small></span><button type="button" class="ibtn" id="sgmore" aria-label="Chat options" title="Chat options">${icon('more')}</button></div>
|
||||||
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
<div class="sgmsgs" id="sgmsgs"></div><div id="sgrep" class="sgrep" hidden></div><div id="sgtray" class="sgtray" hidden></div>
|
||||||
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
<form class="sgc" id="sgform"><button type="button" class="ibtn" id="sgat" aria-label="Attach a file">${icon('clip')}</button><input type="file" id="sgfile" multiple hidden>
|
||||||
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
<textarea id="sgtext" rows="1" placeholder="Message" enterkeyhint="send"></textarea><button class="ibtn sgsend" id="sgsend" aria-label="Send">${icon('send')}</button></form>`;
|
||||||
@@ -186,7 +192,7 @@ function sgChatMenu(id) {
|
|||||||
}
|
}
|
||||||
function sgGone(id, fromServer) {
|
function sgGone(id, fromServer) {
|
||||||
sg.convs = sg.convs.filter(x => x.id !== id);
|
sg.convs = sg.convs.filter(x => x.id !== id);
|
||||||
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sgOnView()) location.hash = '#/signal'; }
|
if (sg.cur === id) { sg.cur = null; sgSaveDock(); if (sg.dock && sgMounted()) sgOpen(null); else if (sg.inChat) { location.hash = '#/chat'; } else if (sgOnView()) location.hash = '#/signal'; }
|
||||||
if (!fromServer || sgMounted()) sgRefresh();
|
if (!fromServer || sgMounted()) sgRefresh();
|
||||||
}
|
}
|
||||||
function sgEditContact(c, after) {
|
function sgEditContact(c, after) {
|
||||||
@@ -205,7 +211,7 @@ async function sgNewChat() {
|
|||||||
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
|
const input = el.querySelector('#sgnn'), draw = () => { const q = input.value.toLowerCase().trim(); el.querySelector('#sgcl').innerHTML = contacts.filter(c => !q || (c.name || '').toLowerCase().includes(q) || c.number.includes(q)).slice(0, 60).map(c => `<div class="row it"><button type="button" class="rmain" data-n="${esc(c.number)}"><span class="sgav" style="background:${sgColor(c.number)}">${esc(sgInitial(c.name || c.number))}</span><span class="grow"><b>${esc(c.name || c.number)}</b><small>${esc(c.number)}</small></span></button><button type="button" class="ibtn" data-e="${esc(c.number)}" aria-label="Edit ${esc(c.name || c.number)}" title="Edit contact">${icon('edit')}</button></div>`).join('');
|
||||||
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
|
el.querySelectorAll('[data-n]').forEach(b => b.onclick = () => go(b.dataset.n));
|
||||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
|
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => { const k = contacts.find(x => x.number === b.dataset.e); sgEditContact(k, async () => { try { contacts = (await sgApi('/contacts')).contacts; } catch {} }); }); };
|
||||||
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = '#/signal/' + encodeURIComponent(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
const go = async n => { try { const r = await sgPost('/new', { number: n, name: el.querySelector('#sgnm').value }); closeSheet(); if (sg.dock) { await sgRefresh(); sgOpen(r.conv.id); } else location.hash = sgHref(r.conv.id); } catch (e) { el.querySelector('#sgne').textContent = e.message; } };
|
||||||
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
input.oninput = draw; draw(); el.querySelector('#sgnf').onsubmit = e => { e.preventDefault(); go(input.value); }; setTimeout(() => input.focus(), 60);
|
||||||
}
|
}
|
||||||
function sgLinkScreen(root, dock) {
|
function sgLinkScreen(root, dock) {
|
||||||
|
|||||||
+4
-1
@@ -689,7 +689,7 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
||||||
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
||||||
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||||
.octile.rc { padding: 10px 12px; }
|
.octile.rc { padding: 10px 12px; } .octile .ic.ofic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; color: #fff; flex: none; } .octile .ic.ofic svg { width: 20px; height: 20px; margin-top: -5px; } .octile .ic.ofic em { bottom: 3px; }
|
||||||
.orow .rmain { min-width: 0; }
|
.orow .rmain { min-width: 0; }
|
||||||
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
||||||
|
|
||||||
@@ -1109,3 +1109,6 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
|
|||||||
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
||||||
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
|
.gifgrid { columns: 3 110px; column-gap: 6px; margin: 8px 0; } .gifc { display: block; width: 100%; margin: 0 0 6px; padding: 0; border: 0; border-radius: 10px; overflow: hidden; background: var(--panel2); break-inside: avoid; min-height: 0; cursor: pointer; } .gifc img { display: block; width: 100%; height: auto; } .gifc:focus-visible { outline: 3px solid var(--accent); }
|
||||||
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
|
.srcbadge { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px; background: var(--panel2); color: var(--muted); font-size: 11px; vertical-align: middle; }
|
||||||
|
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
|
||||||
|
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
|
||||||
|
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v130';
|
const CACHE = 'hub-v134';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+7
-7
@@ -193,26 +193,26 @@ function chooseFolder(title, start, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
|
// Blank Office files come from valid templates (an empty 0-byte .docx/.xlsx/.pptx often will not open in the editor).
|
||||||
async function createFromTemplate(repo, dir, name, x) {
|
async function createFromTemplate(repo, dir, name, x, tpl) {
|
||||||
const enc = encodeURIComponent;
|
const enc = encodeURIComponent;
|
||||||
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
|
if (!['docx', 'xlsx', 'pptx'].includes(x)) {
|
||||||
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
|
await sf.req(`/api2/repos/${enc(repo)}/file/?p=${enc(joinP(dir, name))}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'create' }) });
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
const tpl = await fetch('/templates/blank.' + x); if (!tpl.ok) throw new Error('Template missing');
|
const tplRes = await fetch('/templates/' + (tpl || 'blank.' + x)); if (!tplRes.ok) throw new Error('Template missing');
|
||||||
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
|
const link = await sf.req(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`);
|
||||||
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tpl.blob(), name);
|
const fd = new FormData(); fd.append('parent_dir', dir); fd.append('file', await tplRes.blob(), name);
|
||||||
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
|
const r = await fetch('/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1', { method: 'POST', body: fd });
|
||||||
if (!r.ok) throw new Error('Could not create ' + name);
|
if (!r.ok) throw new Error('Could not create ' + name);
|
||||||
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
|
return ((await r.json())[0] || {}).name || name; // Seafile renames if the name is taken
|
||||||
}
|
}
|
||||||
|
|
||||||
async function newOfficeDoc(x, label) {
|
async function newOfficeDoc(x, label, tpl, defName) {
|
||||||
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
|
if (!sf.token()) { toast('Sign in to Files first'); location.hash = '#/files'; return; }
|
||||||
let loc;
|
let loc;
|
||||||
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
|
try { loc = await officeLocation(true); } catch (e) { return toast(e.message); }
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
const el = openSheet('New ' + label, '', `<form><label for="nn">File name</label><input type="text" id="nn" value="Untitled" autocomplete="off" autocapitalize="off" required>
|
const el = openSheet(tpl ? 'New from template' : 'New ' + label, tpl ? label : '', `<form><label for="nn">File name</label><input type="text" id="nn" value="${esc(defName || 'Untitled')}" autocomplete="off" autocapitalize="off" required>
|
||||||
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
|
<label>Saved in</label><button type="button" class="row" id="nl" style="background:var(--panel2);border-radius:12px;border:0"><span class="grow"><b id="nlt"></b></span><small style="color:var(--accent)">Change</small></button>
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Create</button></div><div class="err" id="ne"></div></form>`);
|
||||||
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
|
const showLoc = () => { el.querySelector('#nlt').textContent = locLabel(loc); };
|
||||||
@@ -221,7 +221,7 @@ async function newOfficeDoc(x, label) {
|
|||||||
el.querySelector('#nl').onclick = async () => {
|
el.querySelector('#nl').onclick = async () => {
|
||||||
// Sheets don't stack, so close this one, choose, then reopen with the new location.
|
// Sheets don't stack, so close this one, choose, then reopen with the new location.
|
||||||
const picked = await chooseFolder('Save in…', loc);
|
const picked = await chooseFolder('Save in…', loc);
|
||||||
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label);
|
if (picked) { store.set('office_loc', picked); } newOfficeDoc(x, label, tpl, defName);
|
||||||
};
|
};
|
||||||
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
|
setTimeout(() => { const t = el.querySelector('#nn'); t.focus(); t.select(); }, 50);
|
||||||
el.querySelector('form').onsubmit = async e => {
|
el.querySelector('form').onsubmit = async e => {
|
||||||
@@ -230,7 +230,7 @@ async function newOfficeDoc(x, label) {
|
|||||||
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
|
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
|
||||||
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
|
const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
const name = await createFromTemplate(loc.repo, loc.dir, want, x), path = joinP(loc.dir, name);
|
const name = await createFromTemplate(loc.repo, loc.dir, want, x, tpl), path = joinP(loc.dir, name);
|
||||||
addRecent(loc.repo, path, name); closeSheet();
|
addRecent(loc.repo, path, name); closeSheet();
|
||||||
if (cfg.editor) location.hash = officeHash(loc.repo, path);
|
if (cfg.editor) location.hash = officeHash(loc.repo, path);
|
||||||
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
|
else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,168 @@
|
|||||||
|
# Makes the ready-made Office templates in public/templates. Needs: pip install python-docx openpyxl python-pptx. Run: python3 tools/make-office-templates.py public/templates
|
||||||
|
import sys, os
|
||||||
|
from docx import Document
|
||||||
|
from docx.shared import Pt, RGBColor, Inches
|
||||||
|
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from pptx import Presentation
|
||||||
|
from pptx.util import Inches as PI, Pt as PP
|
||||||
|
from pptx.dml.color import RGBColor as PC
|
||||||
|
|
||||||
|
OUT = sys.argv[1]; os.makedirs(OUT, exist_ok=True)
|
||||||
|
BLUE = '3D6BF0'
|
||||||
|
|
||||||
|
# ---------------- Word
|
||||||
|
def base(doc, size=11):
|
||||||
|
st = doc.styles['Normal']; st.font.name = 'Calibri'; st.font.size = Pt(size)
|
||||||
|
for s in doc.sections: s.left_margin = s.right_margin = Inches(1); s.top_margin = s.bottom_margin = Inches(0.9)
|
||||||
|
def para(doc, text='', bold=False, size=None, color=None, align=None, after=6, italic=False):
|
||||||
|
p = doc.add_paragraph(); r = p.add_run(text); r.bold = bold; r.italic = italic
|
||||||
|
if size: r.font.size = Pt(size)
|
||||||
|
if color: r.font.color.rgb = RGBColor.from_string(color)
|
||||||
|
if align: p.alignment = align
|
||||||
|
p.paragraph_format.space_after = Pt(after); return p
|
||||||
|
def heading(doc, text, size=13):
|
||||||
|
p = para(doc, text.upper(), bold=True, size=size - 2, color=BLUE, after=3); p.paragraph_format.space_before = Pt(10)
|
||||||
|
return p
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Your name]', bold=True, size=14, after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=0); para(d, '[Phone] · [Email]', after=14)
|
||||||
|
para(d, '[Date]', after=14); para(d, '[Recipient name]', after=0); para(d, '[Title or company]', after=0); para(d, '[Street address]', after=0); para(d, '[City, State ZIP]', after=14)
|
||||||
|
para(d, 'Dear [Name],', after=10)
|
||||||
|
para(d, 'Start with the reason you are writing, in a sentence or two. Keep it friendly and direct.', after=10)
|
||||||
|
para(d, 'Use the middle paragraph for the details: what you need, what happened, or what you are offering. Short paragraphs are easier to read.', after=10)
|
||||||
|
para(d, 'Close by saying what happens next, and thank them for their time.', after=14)
|
||||||
|
para(d, 'Sincerely,', after=30); para(d, '[Your name]', after=0)
|
||||||
|
d.save(f'{OUT}/letter.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 10.5)
|
||||||
|
para(d, '[Your Name]', bold=True, size=24, after=0); para(d, '[City, State] · [Phone] · [Email]', color='666666', after=8)
|
||||||
|
heading(d, 'Summary'); para(d, 'Two or three sentences about who you are, what you do well, and what you are looking for.')
|
||||||
|
heading(d, 'Experience')
|
||||||
|
for i in range(2):
|
||||||
|
para(d, '[Job title], [Employer]', bold=True, after=0); para(d, '[Month Year] – [Month Year]', italic=True, color='666666', after=3)
|
||||||
|
for t in ['What you did and the result it had (use numbers when you can)', 'Something you improved, built or led', 'A skill you used every day']:
|
||||||
|
p = d.add_paragraph(t, style='List Bullet'); p.paragraph_format.space_after = Pt(1)
|
||||||
|
para(d, '', after=4)
|
||||||
|
heading(d, 'Education'); para(d, '[School], [Degree or program]', bold=True, after=0); para(d, '[Year]', italic=True, color='666666')
|
||||||
|
heading(d, 'Skills'); para(d, '[Skill] · [Skill] · [Skill] · [Skill] · [Skill]')
|
||||||
|
d.save(f'{OUT}/resume.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, 'Meeting notes', bold=True, size=22, color=BLUE, after=2); para(d, '[Meeting name]', size=13, after=10)
|
||||||
|
t = d.add_table(rows=4, cols=2); t.style = 'Light Grid Accent 1'
|
||||||
|
for i, (a, b) in enumerate([('Date', '[Date and time]'), ('Where', '[Place or link]'), ('Who was there', '[Names]'), ('Taking notes', '[Name]')]): t.cell(i, 0).text = a; t.cell(i, 1).text = b
|
||||||
|
para(d, '', after=4); heading(d, 'Agenda')
|
||||||
|
for x in ['[First topic]', '[Second topic]', '[Third topic]']: d.add_paragraph(x, style='List Number')
|
||||||
|
heading(d, 'Notes'); para(d, 'What was said and what was decided.', italic=True, color='666666'); para(d, '', after=30)
|
||||||
|
heading(d, 'Action items')
|
||||||
|
t = d.add_table(rows=4, cols=3); t.style = 'Light Grid Accent 1'
|
||||||
|
for i, h in enumerate(['What needs doing', 'Who', 'By when']): t.cell(0, i).text = h
|
||||||
|
d.save(f'{OUT}/meeting-notes.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Family Name] News', bold=True, size=28, color=BLUE, align=WD_ALIGN_PARAGRAPH.CENTER, after=0); para(d, '[Month Year]', align=WD_ALIGN_PARAGRAPH.CENTER, color='666666', after=14)
|
||||||
|
heading(d, 'The big news'); para(d, 'Share the headline: a trip, a birthday, a new job, a new pet. A few friendly sentences is plenty.')
|
||||||
|
heading(d, 'What everyone has been up to')
|
||||||
|
for n in ['[Name]', '[Name]', '[Name]']: para(d, n, bold=True, after=0); para(d, 'A line or two about school, work, hobbies or something funny.', after=8)
|
||||||
|
heading(d, 'Coming up'); t = d.add_table(rows=4, cols=2); t.style = 'Light List Accent 1'
|
||||||
|
t.cell(0, 0).text = 'Date'; t.cell(0, 1).text = 'What'
|
||||||
|
heading(d, 'A favorite moment'); para(d, 'Add a photo here (Insert → Image) and a sentence about it.', italic=True, color='666666')
|
||||||
|
d.save(f'{OUT}/family-newsletter.docx')
|
||||||
|
|
||||||
|
d = Document(); base(d, 11)
|
||||||
|
para(d, '[Recipe name]', bold=True, size=24, color=BLUE, after=2); para(d, 'Serves [4] · Prep [15 min] · Cook [30 min]', color='666666', after=10)
|
||||||
|
heading(d, 'Ingredients')
|
||||||
|
for x in ['[2 cups flour]', '[1 tsp salt]', '[1 cup milk]', '[2 eggs]']: d.add_paragraph(x, style='List Bullet')
|
||||||
|
heading(d, 'Steps')
|
||||||
|
for x in ['[First step]', '[Second step]', '[Third step]']: d.add_paragraph(x, style='List Number')
|
||||||
|
heading(d, 'Notes'); para(d, 'Where it came from, what to change next time, what goes well with it.', italic=True, color='666666')
|
||||||
|
d.save(f'{OUT}/recipe-card.docx')
|
||||||
|
|
||||||
|
# ---------------- Excel
|
||||||
|
thin = Side(style='thin', color='D0D5DD'); BOX = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||||
|
HEAD = PatternFill('solid', fgColor=BLUE); SOFT = PatternFill('solid', fgColor='EEF2FF')
|
||||||
|
def hdr(ws, row, values, widths=None):
|
||||||
|
for i, v in enumerate(values, 1):
|
||||||
|
c = ws.cell(row=row, column=i, value=v); c.font = Font(bold=True, color='FFFFFF'); c.fill = HEAD; c.alignment = Alignment(horizontal='center', vertical='center', wrap_text=True); c.border = BOX
|
||||||
|
if widths:
|
||||||
|
for i, w in enumerate(widths, 1): ws.column_dimensions[get_column_letter(i)].width = w
|
||||||
|
def title(ws, text, sub=None):
|
||||||
|
ws['A1'] = text; ws['A1'].font = Font(bold=True, size=18, color=BLUE)
|
||||||
|
if sub: ws['A2'] = sub; ws['A2'].font = Font(italic=True, color='666666')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Budget'; title(ws, 'Monthly budget', 'Fill in the planned and actual amounts. The last column does the math.')
|
||||||
|
ws['A4'] = 'Income'; ws['A4'].font = Font(bold=True, size=12)
|
||||||
|
hdr(ws, 5, ['Item', 'Planned', 'Actual', 'Difference'], [30, 14, 14, 14])
|
||||||
|
inc = ['Paycheck', 'Second income', 'Other']
|
||||||
|
for i, n in enumerate(inc):
|
||||||
|
r = 6 + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=C{r}-B{r}')
|
||||||
|
r = 6 + len(inc); ws.cell(r, 1, 'Total income').font = Font(bold=True); ws.cell(r, 2, f'=SUM(B6:B{r-1})'); ws.cell(r, 3, f'=SUM(C6:C{r-1})'); ws.cell(r, 4, f'=C{r}-B{r}'); inc_total = r
|
||||||
|
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
|
||||||
|
start = r + 2; ws.cell(start, 1, 'Expenses').font = Font(bold=True, size=12); hdr(ws, start + 1, ['Item', 'Planned', 'Actual', 'Difference'])
|
||||||
|
exp = ['Rent or mortgage', 'Utilities', 'Groceries', 'Transportation', 'Insurance', 'Phone and internet', 'Eating out', 'Kids and school', 'Health', 'Giving', 'Savings', 'Fun', 'Other']
|
||||||
|
first = start + 2
|
||||||
|
for i, n in enumerate(exp):
|
||||||
|
r = first + i; ws.cell(r, 1, n); ws.cell(r, 2, 0); ws.cell(r, 3, 0); ws.cell(r, 4, f'=B{r}-C{r}')
|
||||||
|
last = first + len(exp) - 1; r = last + 1; ws.cell(r, 1, 'Total expenses'); ws.cell(r, 2, f'=SUM(B{first}:B{last})'); ws.cell(r, 3, f'=SUM(C{first}:C{last})'); ws.cell(r, 4, f'=B{r}-C{r}'); exp_total = r
|
||||||
|
for c in range(1, 5): ws.cell(r, c).fill = SOFT; ws.cell(r, c).font = Font(bold=True)
|
||||||
|
r += 2; ws.cell(r, 1, 'Left over (income − expenses)').font = Font(bold=True, size=12); ws.cell(r, 2, f'=B{inc_total}-B{exp_total}'); ws.cell(r, 3, f'=C{inc_total}-C{exp_total}')
|
||||||
|
for row in ws.iter_rows(min_row=6, max_row=r):
|
||||||
|
for c in row[1:4]: c.number_format = '$#,##0.00;[Red]-$#,##0.00'
|
||||||
|
for c in row[:4]:
|
||||||
|
if c.row not in (start, start + 1, r) and c.value is not None: c.border = BOX
|
||||||
|
ws.freeze_panes = 'A6'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/monthly-budget.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Chores'; title(ws, 'Weekly chore chart', 'Put a name in each box, or a check mark when it is done.')
|
||||||
|
days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; hdr(ws, 4, ['Chore'] + days + ['Points'], [26] + [10] * 7 + [10])
|
||||||
|
chores = ['Make bed', 'Set the table', 'Clear the table', 'Dishes / dishwasher', 'Feed the pets', 'Take out trash', 'Tidy bedroom', 'Laundry', 'Vacuum', 'Water plants']
|
||||||
|
for i, n in enumerate(chores):
|
||||||
|
r = 5 + i; ws.cell(r, 1, n)
|
||||||
|
for c in range(1, 10): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(horizontal='center' if c > 1 else 'left')
|
||||||
|
ws.cell(r, 9, f'=COUNTA(B{r}:H{r})')
|
||||||
|
r = 5 + len(chores); ws.cell(r, 1, 'Total').font = Font(bold=True); ws.cell(r, 9, f'=SUM(I5:I{r-1})'); ws.cell(r, 9).font = Font(bold=True)
|
||||||
|
for c in range(1, 10): ws.cell(r, c).fill = SOFT; ws.cell(r, c).border = BOX
|
||||||
|
ws.freeze_panes = 'B5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/chore-chart.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Meals'; title(ws, 'Weekly meal planner', 'Plan the week, then build the shopping list from it.')
|
||||||
|
hdr(ws, 4, ['', 'Breakfast', 'Lunch', 'Dinner', 'Snacks'], [14, 24, 24, 28, 22])
|
||||||
|
for i, d_ in enumerate(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']):
|
||||||
|
r = 5 + i; ws.cell(r, 1, d_).font = Font(bold=True); ws.cell(r, 1).fill = SOFT
|
||||||
|
for c in range(1, 6): ws.cell(r, c).border = BOX; ws.cell(r, c).alignment = Alignment(vertical='top', wrap_text=True)
|
||||||
|
ws.row_dimensions[r].height = 34
|
||||||
|
ws.cell(13, 1, 'Shopping list').font = Font(bold=True, size=12); ws.cell(14, 1, 'Write what you need to buy under here.').font = Font(italic=True, color='666666')
|
||||||
|
ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/meal-planner.xlsx')
|
||||||
|
|
||||||
|
wb = Workbook(); ws = wb.active; ws.title = 'Expenses'; title(ws, 'Expense tracker', 'One line per purchase. The totals below add themselves up.')
|
||||||
|
hdr(ws, 4, ['Date', 'What', 'Category', 'Paid with', 'Amount'], [14, 32, 18, 16, 14])
|
||||||
|
for r in range(5, 45):
|
||||||
|
for c in range(1, 6): ws.cell(r, c).border = BOX
|
||||||
|
ws.cell(r, 1).number_format = 'mmm d, yyyy'; ws.cell(r, 5).number_format = '$#,##0.00'
|
||||||
|
ws['D46'] = 'Total'; ws['D46'].font = Font(bold=True); ws['E46'] = '=SUM(E5:E44)'; ws['E46'].number_format = '$#,##0.00'; ws['E46'].font = Font(bold=True)
|
||||||
|
ws['G4'] = 'By category'; ws['G4'].font = Font(bold=True, color='FFFFFF'); ws['G4'].fill = HEAD; ws['H4'] = 'Total'; ws['H4'].font = Font(bold=True, color='FFFFFF'); ws['H4'].fill = HEAD; ws.column_dimensions['G'].width = 18; ws.column_dimensions['H'].width = 14
|
||||||
|
for i, n in enumerate(['Groceries', 'Gas', 'Eating out', 'Home', 'Kids', 'Health', 'Fun', 'Other']):
|
||||||
|
r = 5 + i; ws.cell(r, 7, n); ws.cell(r, 8, f'=SUMIF($C$5:$C$44,G{r},$E$5:$E$44)'); ws.cell(r, 8).number_format = '$#,##0.00'
|
||||||
|
ws.freeze_panes = 'A5'; ws.sheet_view.showGridLines = False; wb.save(f'{OUT}/expense-tracker.xlsx')
|
||||||
|
|
||||||
|
# ---------------- PowerPoint
|
||||||
|
prs = Presentation(); prs.slide_width = PI(13.333); prs.slide_height = PI(7.5)
|
||||||
|
def slide(title_text, lines=None, layout=1, sub=None):
|
||||||
|
s = prs.slides.add_slide(prs.slide_layouts[layout]); s.shapes.title.text = title_text
|
||||||
|
for p in s.shapes.title.text_frame.paragraphs:
|
||||||
|
for r in p.runs: r.font.color.rgb = PC.from_string(BLUE); r.font.bold = True; r.font.name = 'Calibri'
|
||||||
|
if layout == 0 and sub is not None: s.placeholders[1].text = sub
|
||||||
|
elif lines is not None:
|
||||||
|
tf = s.placeholders[1].text_frame; tf.clear()
|
||||||
|
for i, t in enumerate(lines):
|
||||||
|
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.text = t; p.font.size = PP(26); p.font.name = 'Calibri'
|
||||||
|
return s
|
||||||
|
slide('[Presentation title]', layout=0, sub='[Your name] · [Date]')
|
||||||
|
slide('Agenda', ['[First topic]', '[Second topic]', '[Third topic]', 'Questions'])
|
||||||
|
slide('[A key point]', ['Say it in one clear sentence', 'Back it up with two or three short points', 'Add a picture or a chart on the right (Insert → Image)'])
|
||||||
|
slide('[Another key point]', ['Keep to a few words per line', 'Let the pictures do the talking', 'Practice saying the rest out loud'])
|
||||||
|
slide('Summary', ['What we covered', 'What happens next', 'Who does what'])
|
||||||
|
slide('Thank you', layout=0, sub='[Contact or question prompt]')
|
||||||
|
prs.save(f'{OUT}/simple-presentation.pptx')
|
||||||
|
print(sorted(os.listdir(OUT)))
|
||||||
Reference in new issue
Block a user