add footer

This commit is contained in:
Joey Kimsey
2026-09-12 15:16:50 -04:00
parent 58ca586d94
commit ded1e1e069
5 changed files with 475 additions and 59 deletions

View File

@ -13,4 +13,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens. MFA accounts continue in-app (`api/login/mfa/{loginCode}`) until a token is issued. `loginCode` stays in the main process; a pasted Admin token skips MFA.
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
- Logged-in chrome matches TTP: navy header, Font Awesome 6.7.1 / Bootstrap 5.3, centered full-width search, notifications and messages dropdowns, avatar account menu. Profile settings (avatar, name, gender, newsletter, timezone, date/time, page size, dark mode) live in-app and save through `POST /api/profile/update`. Email, password, and phone open the connected site.
- After sign-in, Capsule loads `GET /api/profile` plus notifications and messages. Search, contact, and bug reports use the matching user-token endpoints. Disabled plugins show an unavailable note instead of demo data.
- After sign-in, Capsule loads `GET /api/profile` plus notifications and messages. Search uses the matching user-token endpoint. Contact and bug reports live on footer pages (`#/contact`, `#/bugreport`) instead of the dashboard. Disabled plugins show an unavailable note instead of demo data.
- Footer chrome matches TTP copy and socials. The upper band keeps a dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. There is no subscribe box.

View File

@ -29,7 +29,7 @@ All HTTP runs in the **main process**. The renderer never sees the token and nev
| Search | `GET /api/search` | Header search. `q`, `resource`, `page`. |
| Profile save | `POST /api/profile/update` | Name, avatar, prefs. |
| Mail / notices | `POST /api/messages/?`, `POST /api/notifications/?` | View, reply, create, read, delete. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Dashboard forms when those plugins are enabled. |
| Contact / bugs | `POST /api/contact`, `POST /api/bugreport` | Footer pages when those plugins are enabled. |
| Existing token | Admin ? Tokens | Personal or app token. A user token hydrates the workspace; an app token can connect but cannot call the user API. |
The token is stored under Electron `userData` (`session.json`). `safeStorage` encrypts it when the OS keychain is available. MFA `loginCode` is not stored.
@ -44,7 +44,7 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
| `src/preload/` | `window.capsule` bridge |
| `src/renderer/` | Login, MFA, TTP-styled chrome, and live API views |
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in.
The logged-in header follows the public TTP shell (`text-bg-dark`, FA 6.7.1, Bootstrap 5.3). Search stays visible and centered. Account is a top-right dropdown like the site. Notifications and messages are the same bell / envelope menus. Profile edit covers User CP preferences except email, password, and phone ? those open `{site}/usercp/?`. Lists load from the site API after sign-in. The footer matches TTP copyright and social icons. Above that: dark-mode toggle, Privacy Policy, Terms of Service, Contact, and Report a Bug. No subscribe box. Privacy and terms open the connected site; contact and bug reports stay in-app.
## Remote

View File

@ -344,62 +344,174 @@
<div class="m-2 m-lg-4">
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h3">Dashboard</h1>
<h1 class="h3">Workspace</h1>
<p class="text-muted" id="home-note">
Search, notifications, messages, and profile are here. Site API wiring is the next
step.
</p>
<p class="text-muted" id="home-note"></p>
<p class="mb-0">
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
as <strong id="home-username"></strong>.
</p>
<p id="home-plugins" class="small text-muted mb-4"></p>
<div class="row g-4">
<div class="col-md-6" id="contact-panel" hidden>
<h2 class="h5">Contact</h2>
<p id="home-plugins" class="small text-muted mb-0"></p>
</div>
</div>
</section>
<section id="view-contact" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="context-main-bg container py-2 my-2 py-lg-4 my-lg-4">
<h1 class="h2 text-center mb-4">Contact Us</h1>
<p id="contact-unavailable" class="text-muted text-center" hidden></p>
<div id="contact-body">
<div class="col-12 col-lg-6 offset-lg-3">
<p class="text-center text-lg-start">
Here at <strong id="contact-sitename">this site</strong>, we highly value your
feedback. We constantly strive to provide our users with the highest level of
quality in everything we do.
</p>
<p class="text-center text-lg-start">
If you would like to provide any suggestions or comments on our service, we ask
that you please fill out the quick form below and let us know what's on your mind.
</p>
</div>
<form id="contact-form">
<div class="mb-2">
<label class="form-label" for="contact-name">Name</label>
<input id="contact-name" class="form-control" name="name" type="text" required />
<div class="mb-3 row">
<label for="contact-name" class="col-lg-3 col-form-label text-lg-end">Name:</label>
<div class="col-lg-6">
<input
id="contact-name"
class="form-control"
name="name"
type="text"
required
autocomplete="name"
/>
</div>
<div class="mb-2">
<label class="form-label" for="contact-email">Email</label>
<input id="contact-email" class="form-control" name="email" type="email" />
</div>
<div class="mb-2">
<label class="form-label" for="contact-entry">Message</label>
<textarea id="contact-entry" class="form-control" name="entry" rows="3" required></textarea>
<div class="mb-3 row">
<label for="contact-email" class="col-lg-3 col-form-label text-lg-end">
E-mail: (optional)
</label>
<div class="col-lg-6">
<input
id="contact-email"
class="form-control"
name="email"
type="email"
autocomplete="email"
/>
</div>
</div>
<div class="mb-3 row">
<label for="contact-entry" class="col-lg-3 col-form-label text-lg-end">Feedback:</label>
<div class="col-lg-6">
<textarea
id="contact-entry"
class="form-control"
name="entry"
rows="6"
maxlength="2000"
required
></textarea>
<small class="form-text text-muted">Max: 2000 characters</small>
</div>
</div>
<p id="contact-status" class="small text-center" hidden></p>
<div class="text-center">
<button class="btn btn-primary btn-lg" type="submit">Submit</button>
</div>
<p id="contact-status" class="small" hidden></p>
<button class="btn btn-primary" type="submit">Send</button>
</form>
</div>
<div class="col-md-6" id="bugreport-panel" hidden>
<h2 class="h5">Bug report</h2>
</div>
</div>
</section>
<section id="view-bugreport" class="view" hidden>
<div class="m-2 m-lg-4">
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
<h1 class="h2 text-center mb-4">Report a Bug</h1>
<hr />
<p id="bug-unavailable" class="text-muted text-center" hidden></p>
<div id="bug-body">
<p class="text-center text-sm-start">
Thank you for visiting our bug reporting page. We value our users' input highly and
in an effort to better serve your needs, please fill out the form below to help us
address this issue.
</p>
<p class="text-center text-sm-start">
We read each and every bug report submitted, and by submitting this form you allow
us to send you a follow-up email.
</p>
<form id="bugreport-form">
<div class="mb-2">
<label class="form-label" for="bug-url">Page URL</label>
<input id="bug-url" class="form-control" name="url" type="url" required />
<div class="mb-3">
<label class="form-label" for="bug-url">Page you were trying to reach:</label>
<input
id="bug-url"
class="form-control"
name="url"
type="url"
aria-describedby="bug-url-help"
required
/>
<small id="bug-url-help" class="form-text text-muted">
This is the URL of the page you actually received the error.
</small>
</div>
<div class="mb-2">
<label class="form-label" for="bug-ourl">Expected URL <span class="text-muted">(optional)</span></label>
<input id="bug-ourl" class="form-control" name="ourl" type="url" />
<div class="mb-3">
<label class="form-label" for="bug-ourl">Page you were on:</label>
<input
id="bug-ourl"
class="form-control"
name="ourl"
type="url"
aria-describedby="bug-ourl-help"
/>
<small id="bug-ourl-help" class="form-text text-muted">
This is the URL of the page you were on before you received the error.
</small>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="bug-repeat" />
<label class="form-check-label" for="bug-repeat">This happens every time</label>
<div class="mb-3">
<p class="form-label">*Has this happened more than once?</p>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="repeat"
id="bug-repeat-no"
value="false"
checked
/>
<label class="form-check-label" for="bug-repeat-no">No</label>
</div>
<div class="mb-2">
<label class="form-label" for="bug-entry">What happened</label>
<textarea id="bug-entry" class="form-control" name="entry" rows="3" required></textarea>
<div class="form-check">
<input
class="form-check-input"
type="radio"
name="repeat"
id="bug-repeat-yes"
value="true"
/>
<label class="form-check-label" for="bug-repeat-yes">Yes</label>
</div>
</div>
<div class="mb-3">
<label class="form-label" for="bug-entry">Describe the error you received:</label>
<textarea
id="bug-entry"
class="form-control"
name="entry"
rows="6"
maxlength="2000"
aria-describedby="bug-entry-help"
required
></textarea>
<small id="bug-entry-help" class="form-text text-muted">(max: 2000 characters)</small>
</div>
<p id="bug-status" class="small text-center" hidden></p>
<div class="text-center">
<button class="btn btn-primary btn-lg" type="submit">Submit</button>
</div>
<p id="bug-status" class="small" hidden></p>
<button class="btn btn-primary" type="submit">Send report</button>
</form>
</div>
</div>
</div>
</div>
</section>
<section id="view-search" class="view" hidden>
@ -620,6 +732,116 @@
</section>
</main>
<div class="container mt-auto">
<footer class="pt-4">
<div class="text-center border-top context-main-border">
<button
class="d-md-none my-3 btn btn-lg context-main context-main-border"
type="button"
data-bs-toggle="collapse"
data-bs-target="#footerMenu"
aria-controls="footerMenu"
aria-expanded="false"
aria-label="Toggle footer navigation"
>
<i class="fa fa-bars"></i>
</button>
</div>
<div class="collapse d-md-block my-4" id="footerMenu">
<div class="row">
<div id="footer-contact-col" class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<h2 class="h5">Contact Us</h2>
<ul class="nav flex-column">
<li id="footer-link-contact" class="nav-item mb-2">
<a href="#/contact" class="nav-link p-0 text-muted">Contact</a>
</li>
<li id="footer-link-bug" class="nav-item mb-2">
<a href="#/bugreport" class="nav-link p-0 text-muted">Report a Bug</a>
</li>
</ul>
</div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<h2 class="h5">More Info</h2>
<ul class="nav flex-column">
<li class="nav-item mb-2">
<a
id="footer-privacy"
href="#"
class="nav-link p-0 text-muted"
target="_blank"
rel="noreferrer"
data-footer-path="home/privacy"
>Privacy Policy</a>
</li>
<li class="nav-item mb-2">
<a
id="footer-terms"
href="#"
class="nav-link p-0 text-muted"
target="_blank"
rel="noreferrer"
data-footer-path="home/terms"
>Terms of Service</a>
</li>
</ul>
</div>
<div class="col-12 col-sm-6 col-md-3 col-lg-2 mb-3 text-center">
<h2 class="h5">Dark Mode</h2>
<div class="material-switch px-4 mt-2">
<input name="dark-mode-toggle" type="checkbox" id="dark-mode-toggle" class="form-check-input" />
<label for="dark-mode-toggle" class="label-default"><span class="visually-hidden">Dark mode</span></label>
</div>
</div>
</div>
</div>
<div class="d-flex flex-column flex-md-row justify-content-md-between py-3 border-top context-main-border">
<div class="d-flex justify-content-center justify-content-md-start text-center text-md-start">
<span>
&copy; <span id="footer-year"></span>
<span id="footer-sitename">Capsule</span>, Powered by
<a
href="https://thetempusproject.com"
class="text-decoration-none"
target="_blank"
rel="noreferrer"
>The Tempus Project</a>
</span>
</div>
<div class="d-flex justify-content-center justify-content-md-end mt-3 mt-md-0">
<ul class="list-unstyled d-flex mb-0">
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="fb" aria-label="Facebook">
<span class="fa-brands fa-fw fa-facebook" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="twitter" aria-label="X (Twitter)">
<span class="fa-brands fa-fw fa-twitter" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="in" aria-label="LinkedIn">
<span class="fa-brands fa-fw fa-linkedin" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="youtube" aria-label="YouTube">
<span class="fa-brands fa-fw fa-youtube" aria-hidden="true"></span>
</a>
</li>
<li class="ms-3">
<a class="context-main" href="#" target="_blank" rel="noreferrer" data-footer-path="git" aria-label="GitHub">
<span class="fa-brands fa-fw fa-github" aria-hidden="true"></span>
</a>
</li>
</ul>
</div>
</div>
</footer>
</div>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"

View File

@ -22,7 +22,9 @@ const views = {
notifications: document.getElementById('view-notifications'),
messages: document.getElementById('view-messages'),
profile: document.getElementById('view-profile'),
settings: document.getElementById('view-settings')
settings: document.getElementById('view-settings'),
contact: document.getElementById('view-contact'),
bugreport: document.getElementById('view-bugreport')
}
const mainPages = {
@ -124,10 +126,28 @@ function hasPlugin(name) {
* @return {string} - absolute URL, or #
*/
function siteHref(path) {
if (!session?.siteUrl) {
const root = session?.siteUrl || session?.lastSiteUrl
if (!root) {
return '#'
}
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
return `${root.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
}
/**
* Name for copyright and contact copy.
*
* @return {string}
*/
function displaySiteName() {
const root = session?.siteUrl || session?.lastSiteUrl
if (!root) {
return 'Capsule'
}
try {
return new URL(root).hostname.replace(/^www\./i, '')
} catch {
return 'Capsule'
}
}
/**
@ -366,6 +386,36 @@ function renderChrome() {
)
.join('')
)
renderFooter()
}
/**
* Copyright, socials, legal links, and contact/bug items.
*
* @return {void}
*/
function renderFooter() {
document.getElementById('footer-year').textContent = String(new Date().getFullYear())
const sitename = displaySiteName()
document.getElementById('footer-sitename').textContent = sitename
document.getElementById('contact-sitename').textContent = sitename
document.querySelectorAll('[data-footer-path]').forEach((link) => {
const href = siteHref(link.dataset.footerPath)
link.href = href
if (href === '#') {
link.removeAttribute('target')
} else {
link.setAttribute('target', '_blank')
}
})
const showContact = Boolean(session?.connected) && (session.preview || hasPlugin('contact'))
const showBug = Boolean(session?.connected) && (session.preview || hasPlugin('bugreport'))
document.getElementById('footer-link-contact').hidden = !showContact
document.getElementById('footer-link-bug').hidden = !showBug
document.getElementById('footer-contact-col').hidden = !showContact && !showBug
}
/**
@ -439,15 +489,21 @@ function renderPages() {
? `Enabled plugins: ${plugins.join(', ')}`
: 'Plugin list will show after profile loads.'
const contactPanel = document.getElementById('contact-panel')
const bugPanel = document.getElementById('bugreport-panel')
contactPanel.hidden = session.preview || !hasPlugin('contact')
bugPanel.hidden = session.preview || !hasPlugin('bugreport')
if (!contactPanel.hidden && profile.name && !document.getElementById('contact-name').value) {
const contactOk = session.preview || hasPlugin('contact')
const bugOk = session.preview || hasPlugin('bugreport')
document.getElementById('contact-body').hidden = !contactOk
document.getElementById('bug-body').hidden = !bugOk
const contactUnavailable = document.getElementById('contact-unavailable')
const bugUnavailable = document.getElementById('bug-unavailable')
contactUnavailable.hidden = contactOk
bugUnavailable.hidden = bugOk
contactUnavailable.textContent = contactOk ? '' : 'Contact is not available on this site.'
bugUnavailable.textContent = bugOk ? '' : 'Bug reports are not available on this site.'
if (contactOk && profile.name && !document.getElementById('contact-name').value) {
document.getElementById('contact-name').value = profile.name || profile.username || ''
document.getElementById('contact-email').value = profile.email || ''
}
if (!bugPanel.hidden && session.siteUrl && !document.getElementById('bug-url').value) {
if (bugOk && session.siteUrl && !document.getElementById('bug-url').value) {
document.getElementById('bug-url').value = session.siteUrl
}
@ -602,7 +658,44 @@ function renderThread() {
* @return {void}
*/
function applyTheme(dark) {
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
const on = Boolean(dark)
document.documentElement.setAttribute('data-bs-theme', on ? 'dark' : 'light')
const footerToggle = document.getElementById('dark-mode-toggle')
const settingsToggle = document.getElementById('settings-dark')
if (footerToggle) {
footerToggle.checked = on
}
if (settingsToggle) {
settingsToggle.checked = on
}
}
/**
* Apply dark mode and save it on a live session.
*
* @param {boolean} dark - dark-mode preference
* @param {boolean} persist - POST api/profile/update when signed in
* @return {Promise<void>}
*/
async function setDarkMode(dark, persist) {
profile.darkMode = Boolean(dark)
applyTheme(profile.darkMode)
if (!persist || session?.preview || !window.capsule || !session?.connected) {
return
}
try {
const data = await runApi(() =>
window.capsule.updateProfile({
fields: { darkMode: profile.darkMode ? '1' : '0' }
})
)
if (data) {
applyProfile(data)
applyTheme(profile.darkMode)
}
} catch {
applyTheme(profile.darkMode)
}
}
/**
@ -703,6 +796,14 @@ function route() {
showView('settings')
return
}
if (path === 'contact') {
showView('contact')
return
}
if (path === 'bugreport') {
showView('bugreport')
return
}
if (path === 'search') {
showView('search')
const params = new URLSearchParams(query || '')
@ -1096,6 +1197,11 @@ document.getElementById('contact-form').addEventListener('submit', async (event)
const button = event.target.querySelector('button[type="submit"]')
button.disabled = true
setMessage(status, '')
if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false
return
}
try {
await runApi(() =>
window.capsule.contact({
@ -1119,12 +1225,17 @@ document.getElementById('bugreport-form').addEventListener('submit', async (even
const button = event.target.querySelector('button[type="submit"]')
button.disabled = true
setMessage(status, '')
if (session?.preview || !window.capsule) {
setMessage(status, 'Saved in preview only.', 'text-muted')
button.disabled = false
return
}
try {
await runApi(() =>
window.capsule.bugreport({
url: document.getElementById('bug-url').value.trim(),
ourl: document.getElementById('bug-ourl').value.trim(),
repeat: document.getElementById('bug-repeat').checked,
repeat: document.getElementById('bug-repeat-yes').checked,
entry: document.getElementById('bug-entry').value
})
)
@ -1203,7 +1314,11 @@ document.getElementById('settings-avatar-file').addEventListener('change', async
})
document.getElementById('settings-dark').addEventListener('change', (event) => {
applyTheme(event.target.checked)
setDarkMode(event.target.checked, true)
})
document.getElementById('dark-mode-toggle').addEventListener('change', (event) => {
setDarkMode(event.target.checked, true)
})
window.addEventListener('hashchange', route)

View File

@ -74,14 +74,92 @@ body {
font-family: var(--ttp-font);
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
#main-content {
flex: 1 0 auto;
}
.context-main-bg {
background: var(--ttp-surface);
color: var(--ttp-text);
}
.context-main {
color: var(--ttp-text);
}
.context-main-border {
border-color: var(--ttp-border) !important;
}
footer {
flex-shrink: 0;
color: var(--ttp-text-muted);
font-size: 0.925rem;
}
footer a.context-main {
text-decoration: none;
}
/**
* Dark-mode footer switch.
*/
.material-switch {
position: relative;
display: inline-block;
width: 50px;
height: 25px;
}
.material-switch input {
opacity: 0;
width: 0;
height: 0;
}
.material-switch .label-default {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--switch-off-bg, var(--ttp-surface-muted));
border-radius: 25px;
transition: background-color 0.3s ease-in-out;
}
.material-switch .label-default::before {
content: '';
position: absolute;
height: 20px;
width: 20px;
border-radius: 50%;
background-color: var(--switch-slider-bg, #fff);
bottom: 2.5px;
left: 5px;
transition: transform 0.3s ease-in-out;
box-shadow: 0 2px 4px #00000033;
}
.material-switch input:checked + .label-default {
background-color: var(--switch-on-bg, var(--ttp-primary));
}
.material-switch input:checked + .label-default::before {
transform: translateX(25px);
}
header.text-bg-dark {
background-color: var(--ttp-chrome) !important;
box-shadow: 0 1px 0 rgba(63, 169, 245, 0.42), 0 10px 28px rgba(12, 25, 41, 0.18);
flex-shrink: 0;
}
.capsule-header {