add main appearance
This commit is contained in:
@ -12,3 +12,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Electron + electron-vite desktop shell with a login view and a connected workspace.
|
- Electron + electron-vite desktop shell with a login view and a connected workspace.
|
||||||
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens.
|
- Password sign-in through `POST /api/login` and optional connect-with-token from Admin ? Tokens.
|
||||||
- Session stored in `userData`, encrypted with `safeStorage` when the OS allows it. The renderer never receives the token.
|
- 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, gender, newsletter, timezone, date/time, page size, dark mode) live in-app. Email, password, and phone open the connected site.
|
||||||
|
|||||||
@ -6,7 +6,7 @@ This replaces the old `TempusToolkit` Electron stub. The keepers were the login
|
|||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
||||||
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
|
From this folder (Windows source checkout is fine <20> Capsule is Node, not PHP):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@ -17,7 +17,7 @@ npm run dev
|
|||||||
|
|
||||||
## How auth works
|
## How auth works
|
||||||
|
|
||||||
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<54>s same-origin CORS policy does not apply.
|
All HTTP runs in the **main process**. The renderer never sees the token and never talks to the site directly, so TTP<54>s same-origin CORS policy does not apply.
|
||||||
|
|
||||||
| Action | Endpoint | Notes |
|
| Action | Endpoint | Notes |
|
||||||
|--------|----------|-------|
|
|--------|----------|-------|
|
||||||
@ -35,7 +35,9 @@ App-facing pairing notes live with the PHP app: `repos/ttp/docs/capsule.md`.
|
|||||||
|------|-----|
|
|------|-----|
|
||||||
| `src/main/` | Window, session file, TTP HTTP, IPC |
|
| `src/main/` | Window, session file, TTP HTTP, IPC |
|
||||||
| `src/preload/` | `window.capsule` bridge |
|
| `src/preload/` | `window.capsule` bridge |
|
||||||
| `src/renderer/` | Login view and connected workspace |
|
| `src/renderer/` | Login, TTP-styled chrome, search / notifications / messages / profile |
|
||||||
|
|
||||||
|
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 <20> those open `{site}/usercp/<2F>`. Lists are placeholders until the API step.
|
||||||
|
|
||||||
## Remote
|
## Remote
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,10 @@ import { registerSessionIpc } from './sessionIpc.js'
|
|||||||
*/
|
*/
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 920,
|
width: 1180,
|
||||||
height: 640,
|
height: 760,
|
||||||
minWidth: 760,
|
minWidth: 900,
|
||||||
minHeight: 520,
|
minHeight: 600,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
title: 'Capsule',
|
title: 'Capsule',
|
||||||
|
|||||||
@ -1,149 +1,493 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-bs-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; style-src 'self'; script-src 'self'; img-src 'self' data:;"
|
content="default-src 'self'; style-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; font-src https://cdnjs.cloudflare.com; script-src 'self' https://cdn.jsdelivr.net; img-src 'self' data: https: http:;"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Capsule</title>
|
<title>Capsule</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/css/all.min.css"
|
||||||
|
integrity="sha384-QI8z31KmtR+tk1MYi0DfgxrjYgpTpLLol3bqZA/Q1Y8BvH+6k7/Huoj38gQOaCS7"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
/>
|
||||||
<link rel="stylesheet" href="./src/styles.css" />
|
<link rel="stylesheet" href="./src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-shell="login">
|
||||||
<div class="app">
|
<header class="text-bg-dark">
|
||||||
<header class="topbar">
|
<div class="capsule-header p-3">
|
||||||
<div class="brand">
|
<a href="#/" class="capsule-brand text-white text-decoration-none d-flex align-items-center" data-route="home">
|
||||||
|
<img id="header-logo" class="capsule-logo" width="40" height="40" alt="" hidden />
|
||||||
<span class="brand-mark" aria-hidden="true"></span>
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
<div>
|
<span class="brand-name">Capsule</span>
|
||||||
<p class="brand-name">Capsule</p>
|
</a>
|
||||||
<p class="brand-tag">The Tempus Project</p>
|
|
||||||
|
<form id="header-search" class="capsule-search" role="search">
|
||||||
|
<fieldset>
|
||||||
|
<legend class="visually-hidden">Search the site</legend>
|
||||||
|
<label class="visually-hidden" for="search-resource">Search in</label>
|
||||||
|
<label class="visually-hidden" for="search-q">Search terms</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<select class="form-select flex-grow-0 w-auto" name="resource" id="search-resource">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="posts">Posts</option>
|
||||||
|
<option value="pages">Pages</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="form-control"
|
||||||
|
name="q"
|
||||||
|
id="search-q"
|
||||||
|
placeholder="Search"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary" aria-label="Search">
|
||||||
|
<i class="fa fa-fw fa-search" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="capsule-account">
|
||||||
|
<div class="dropdown nav-item mx-2">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
|
||||||
|
id="notificationsDropdown"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Notifications"
|
||||||
|
>
|
||||||
|
<i class="fa fa-fw fa-bell" aria-hidden="true"></i>
|
||||||
|
<span id="notification-badge" class="badge bg-danger rounded-pill">2</span>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
id="notification-menu"
|
||||||
|
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
|
||||||
|
data-bs-theme="dark"
|
||||||
|
aria-labelledby="notificationsDropdown"
|
||||||
|
>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<a href="#/notifications" class="dropdown-item text-center" data-route="notifications">
|
||||||
|
See All Notifications
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown nav-item mx-2">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
|
||||||
|
id="messagesDropdown"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Messages"
|
||||||
|
>
|
||||||
|
<i class="fa fa-fw fa-envelope" aria-hidden="true"></i>
|
||||||
|
<span id="message-badge" class="badge bg-danger rounded-pill">1</span>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
id="message-menu"
|
||||||
|
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
|
||||||
|
data-bs-theme="dark"
|
||||||
|
aria-labelledby="messagesDropdown"
|
||||||
|
>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<a href="#/messages" class="dropdown-item text-center" data-route="messages">All messages</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown nav-item mx-2">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="d-flex align-items-center text-white text-decoration-none dropdown-toggle"
|
||||||
|
id="userDropdown"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-label="Account menu"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
id="header-avatar"
|
||||||
|
src=""
|
||||||
|
alt=""
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
hidden
|
||||||
|
/>
|
||||||
|
<i id="header-avatar-fallback" class="fa fa-user me-2" aria-hidden="true"></i>
|
||||||
|
<strong id="header-username">Account</strong>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu dropdown-menu-dark dropdown-menu-end text-small shadow"
|
||||||
|
data-bs-theme="dark"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<a href="#/profile" class="dropdown-item" data-route="profile">
|
||||||
|
<i class="fa fa-fw fa-user"></i> Profile
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/notifications" class="dropdown-item" data-route="notifications">
|
||||||
|
<i class="fa fa-fw fa-bell"></i> Notifications
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/messages" class="dropdown-item" data-route="messages">
|
||||||
|
<i class="fa fa-fw fa-envelope"></i> Messages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#/settings" class="dropdown-item" data-route="settings">
|
||||||
|
<i class="fa fa-fw fa-gear"></i> Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<a id="link-email" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
|
||||||
|
<i class="fa fa-fw fa-envelope"></i> Change email
|
||||||
|
<i class="fa fa-fw fa-external-link ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a id="link-password" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
|
||||||
|
<i class="fa fa-fw fa-lock"></i> Change password
|
||||||
|
<i class="fa fa-fw fa-external-link ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a id="link-phone" class="dropdown-item" href="#" target="_blank" rel="noreferrer">
|
||||||
|
<i class="fa fa-fw fa-phone"></i> Phone
|
||||||
|
<i class="fa fa-fw fa-external-link ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<button id="logout-button" type="button" class="dropdown-item">
|
||||||
|
<i class="fa fa-fw fa-power-off"></i> Log Out
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p id="top-status" class="top-status" hidden></p>
|
</div>
|
||||||
|
<nav class="capsule-mainnav" aria-label="Main">
|
||||||
|
<ul class="capsule-mainnav-list">
|
||||||
|
<li><a href="#/game" data-nav="game">Game</a></li>
|
||||||
|
<li><a href="#/friends" data-nav="friends">Friends</a></li>
|
||||||
|
<li><a href="#/cabal" data-nav="cabal">Cabal</a></li>
|
||||||
|
<li><a href="#/hiscores" data-nav="hiscores">HiScores</a></li>
|
||||||
|
<li><a href="#/shop" data-nav="shop">Shop</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="stage">
|
<main id="main-content">
|
||||||
<section id="view-login" class="view" hidden>
|
<section id="view-login" class="view" hidden>
|
||||||
<div class="card">
|
<div class="container pt-4">
|
||||||
<h1>Connect a site</h1>
|
<div class="mx-auto p-4 rounded context-main-bg capsule-login-card">
|
||||||
<p class="lede">
|
<h1 class="h3 mb-3 text-center">Connect a site</h1>
|
||||||
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and
|
<p class="text-muted text-center mb-4">
|
||||||
keeps the session on this machine.
|
Sign in to any Tempus Project install. Capsule talks to that site<74>s API and keeps
|
||||||
|
the session on this machine.
|
||||||
</p>
|
</p>
|
||||||
|
<form id="login-form">
|
||||||
<form id="login-form" class="form">
|
<div class="mb-3">
|
||||||
<label class="field">
|
<label class="form-label" for="login-site">Site URL</label>
|
||||||
<span>Site URL</span>
|
|
||||||
<input
|
<input
|
||||||
id="login-site"
|
id="login-site"
|
||||||
|
class="form-control"
|
||||||
name="siteUrl"
|
name="siteUrl"
|
||||||
type="url"
|
type="url"
|
||||||
autocomplete="url"
|
autocomplete="url"
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<div class="mb-3">
|
||||||
<span>Username</span>
|
<label class="form-label" for="login-username">Username</label>
|
||||||
<input id="login-username" name="username" type="text" autocomplete="username" required />
|
<input
|
||||||
</label>
|
id="login-username"
|
||||||
<label class="field">
|
class="form-control"
|
||||||
<span>Password</span>
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="login-password">Password</label>
|
||||||
<input
|
<input
|
||||||
id="login-password"
|
id="login-password"
|
||||||
|
class="form-control"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<p id="login-error" class="error" hidden></p>
|
<p id="login-error" class="text-danger" hidden></p>
|
||||||
<button id="login-submit" class="btn btn-primary" type="submit">Sign in</button>
|
<button id="login-submit" class="btn btn-primary w-100" type="submit">
|
||||||
|
<i class="fa fa-fw fa-right-to-bracket"></i> Sign in
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<details id="token-panel" class="mt-4">
|
||||||
<details id="token-panel" class="token-panel">
|
|
||||||
<summary>Use an API token instead</summary>
|
<summary>Use an API token instead</summary>
|
||||||
<form id="token-form" class="form">
|
<form id="token-form" class="mt-3">
|
||||||
<p class="hint">
|
<p class="small text-muted">
|
||||||
Paste a personal or app token from Admin ? Tokens. Username is optional and
|
Paste a personal or app token from Admin ? Tokens. Username is optional until the
|
||||||
only used to confirm the token against <code>api/users/find</code>.
|
API can identify you.
|
||||||
</p>
|
</p>
|
||||||
<label class="field">
|
<div class="mb-3">
|
||||||
<span>Site URL</span>
|
<label class="form-label" for="token-site">Site URL</label>
|
||||||
<input
|
<input
|
||||||
id="token-site"
|
id="token-site"
|
||||||
|
class="form-control"
|
||||||
name="siteUrl"
|
name="siteUrl"
|
||||||
type="url"
|
type="url"
|
||||||
autocomplete="url"
|
autocomplete="url"
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<div class="mb-3">
|
||||||
<span>API token</span>
|
<label class="form-label" for="token-value">API token</label>
|
||||||
<input id="token-value" name="token" type="password" autocomplete="off" required />
|
<input id="token-value" class="form-control" name="token" type="password" autocomplete="off" required />
|
||||||
</label>
|
</div>
|
||||||
<label class="field">
|
<div class="mb-3">
|
||||||
<span>Username <em>(optional)</em></span>
|
<label class="form-label" for="token-username">Username <span class="text-muted">(optional)</span></label>
|
||||||
<input id="token-username" name="username" type="text" autocomplete="username" />
|
<input id="token-username" class="form-control" name="username" type="text" autocomplete="username" />
|
||||||
</label>
|
</div>
|
||||||
<p id="token-error" class="error" hidden></p>
|
<p id="token-error" class="text-danger" hidden></p>
|
||||||
<button id="token-submit" class="btn btn-secondary" type="submit">
|
<button id="token-submit" class="btn btn-outline-primary w-100" type="submit">
|
||||||
Connect with token
|
Connect with token
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
|
<p class="text-center mt-4 mb-0">
|
||||||
|
<button id="preview-shell" type="button" class="btn btn-link">
|
||||||
|
Preview the desktop chrome
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-stub" class="view" hidden>
|
||||||
|
<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" id="stub-title"></h1>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="view-home" class="view" hidden>
|
<section id="view-home" class="view" hidden>
|
||||||
<div class="home-grid">
|
<div class="m-2 m-lg-4">
|
||||||
<article class="card identity">
|
<div class="col-12 col-lg-10 mx-auto p-4 rounded shadow-sm context-main-bg">
|
||||||
<p class="eyebrow">Connected</p>
|
<h1 class="h3">Workspace</h1>
|
||||||
<h1 id="home-username">Signed in</h1>
|
<p class="text-muted" id="home-note">
|
||||||
<p id="home-site" class="site-line"></p>
|
Search, notifications, messages, and profile are here. Site API wiring is the next
|
||||||
<dl class="meta">
|
step.
|
||||||
<div>
|
</p>
|
||||||
<dt>User ID</dt>
|
<p class="mb-0">
|
||||||
<dd id="home-userid"><EFBFBD></dd>
|
Connected to <a id="home-site" href="#" target="_blank" rel="noreferrer"></a>
|
||||||
|
as <strong id="home-username"></strong>.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<dt>Auth</dt>
|
|
||||||
<dd id="home-method"><EFBFBD></dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</section>
|
||||||
<dt>API</dt>
|
|
||||||
<dd id="home-api"><EFBFBD></dd>
|
<section id="view-search" class="view" hidden>
|
||||||
|
<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">Search</h1>
|
||||||
|
<p class="text-muted" id="search-summary"></p>
|
||||||
|
<p class="mb-0 text-muted">Results will come from the site search API next.</p>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</div>
|
||||||
<p id="home-note" class="hint" hidden></p>
|
</section>
|
||||||
<div class="actions">
|
|
||||||
<button id="logout-button" class="btn btn-secondary" type="button">Sign out</button>
|
<section id="view-notifications" class="view" hidden>
|
||||||
<a id="open-site" class="btn btn-ghost" href="#" target="_blank" rel="noreferrer">
|
<div class="m-2 m-lg-4">
|
||||||
Open site
|
<div class="col-12 col-sm-10 col-lg-8 mx-auto p-4 rounded shadow-sm context-main-bg">
|
||||||
|
<h1 class="h3 text-center">Notifications</h1>
|
||||||
|
<table class="table">
|
||||||
|
<tbody id="notification-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-messages" 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">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h1 class="h3 mb-0">Messages</h1>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" disabled>
|
||||||
|
<i class="fa fa-fw fa-pen"></i> New message
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>With</th>
|
||||||
|
<th>Last message</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="message-list"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="view-profile" class="view" hidden>
|
||||||
|
<div class="container p-4 context-main-bg mb-4 text-center">
|
||||||
|
<h1 class="h3 mb-4">Profile</h1>
|
||||||
|
<hr />
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow">
|
||||||
|
<div class="card-header text-center bg-dark text-white">
|
||||||
|
<h2 class="h3 card-title mb-0" id="profile-name"></h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-4 text-center">
|
||||||
|
<img
|
||||||
|
id="profile-avatar"
|
||||||
|
src=""
|
||||||
|
alt="User Pic"
|
||||||
|
class="rounded-circle img-fluid"
|
||||||
|
style="max-width: 150px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<table class="table table-borderless text-start">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Registered:</th>
|
||||||
|
<td id="profile-registered"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Last Seen:</th>
|
||||||
|
<td id="profile-last-login"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Gender:</th>
|
||||||
|
<td id="profile-gender"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<a href="#/settings" class="btn btn-primary btn-sm" data-route="settings">
|
||||||
|
<i class="fa fa-fw fa-pencil"></i> Edit profile
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<article class="card workspace">
|
<section id="view-settings" class="view" hidden>
|
||||||
<p class="eyebrow">Workspace</p>
|
<div class="container p-4 context-main-bg mb-4">
|
||||||
<h2>Desktop tools land here</h2>
|
<h1 class="h3 mb-4 text-center">Settings</h1>
|
||||||
<p class="lede">
|
<hr />
|
||||||
This area will host the full desktop experience for the plugins enabled on
|
<div class="row justify-content-center">
|
||||||
the connected site. The current API can sign you in, refresh a token, and
|
<div class="col-md-6">
|
||||||
look up a user id. Feature screens wait on those API expansions.
|
<form id="settings-form">
|
||||||
|
<fieldset>
|
||||||
|
<legend class="visually-hidden">Preferences</legend>
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<img
|
||||||
|
id="settings-avatar"
|
||||||
|
src=""
|
||||||
|
alt="Avatar"
|
||||||
|
class="rounded-circle img-fluid mb-2"
|
||||||
|
style="max-width: 125px"
|
||||||
|
/>
|
||||||
|
<label class="form-label d-block" for="settings-avatar-file">Avatar</label>
|
||||||
|
<input id="settings-avatar-file" class="form-control" type="file" accept="image/*" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="settings-gender">Gender</label>
|
||||||
|
<select id="settings-gender" class="form-select" name="gender">
|
||||||
|
<option value="unspecified">unspecified</option>
|
||||||
|
<option value="male">male</option>
|
||||||
|
<option value="female">female</option>
|
||||||
|
<option value="other">other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="settings-newsletter" />
|
||||||
|
<label class="form-check-label" for="settings-newsletter">Receive our Newsletter?</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="settings-timezone">Timezone</label>
|
||||||
|
<select id="settings-timezone" class="form-select" name="timezone"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="settings-date">Date Format</label>
|
||||||
|
<select id="settings-date" class="form-select" name="dateFormat"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="settings-time">Time Format</label>
|
||||||
|
<select id="settings-time" class="form-select" name="timeFormat"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="settings-limit">Items Displayed Per Page</label>
|
||||||
|
<select id="settings-limit" class="form-select" name="pageLimit"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="settings-dark" />
|
||||||
|
<label class="form-check-label" for="settings-dark">Enable Dark-Mode viewing</label>
|
||||||
|
</div>
|
||||||
|
<p id="settings-note" class="small text-muted">
|
||||||
|
Saving will use the site API next. This form matches User CP preferences.
|
||||||
</p>
|
</p>
|
||||||
<ul class="plan">
|
<p id="settings-status" class="text-success" hidden></p>
|
||||||
<li>Talk to a site of your choice with a user login or an API token</li>
|
<button class="btn btn-lg btn-primary w-100" type="submit">Update</button>
|
||||||
<li>Keep the token in the OS keychain, not in the renderer</li>
|
</fieldset>
|
||||||
<li>Load plugin-backed tools as the HTTP API grows</li>
|
</form>
|
||||||
</ul>
|
<div class="alert alert-info mt-4 mb-0">
|
||||||
</article>
|
Email, password, and phone stay on the site.
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
<a id="settings-link-email" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
|
||||||
|
Change email <i class="fa fa-fw fa-external-link"></i>
|
||||||
|
</a>
|
||||||
|
<a id="settings-link-password" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
|
||||||
|
Change password <i class="fa fa-fw fa-external-link"></i>
|
||||||
|
</a>
|
||||||
|
<a id="settings-link-phone" class="btn btn-sm btn-outline-primary" href="#" target="_blank" rel="noreferrer">
|
||||||
|
Phone <i class="fa fa-fw fa-external-link"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
<script
|
||||||
|
src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
<script type="module" src="./src/main.js"></script>
|
<script type="module" src="./src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
96
src/renderer/src/demo.js
Normal file
96
src/renderer/src/demo.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Placeholder lists until the TTP API grows. Shapes match the live plugins.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const previewSession = {
|
||||||
|
connected: true,
|
||||||
|
preview: true,
|
||||||
|
siteUrl: 'https://ttp.joeykimsey.com',
|
||||||
|
username: 'Joey',
|
||||||
|
userId: 1,
|
||||||
|
authMethod: 'password',
|
||||||
|
apiReady: false,
|
||||||
|
lastSiteUrl: 'https://ttp.joeykimsey.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const demoNotifications = [
|
||||||
|
{
|
||||||
|
id: 'n1',
|
||||||
|
unread: true,
|
||||||
|
createdAt: '2 hours ago',
|
||||||
|
html: 'Welcome to Capsule. Notifications will load from the site API next.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'n2',
|
||||||
|
unread: false,
|
||||||
|
createdAt: 'Yesterday',
|
||||||
|
html: 'Your profile preferences can be edited here. Email, password, and phone stay on the site.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const demoMessages = [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
unread: true,
|
||||||
|
otherUser: 'Alex',
|
||||||
|
preview: 'Did the desktop shell pick up the new header?',
|
||||||
|
lastMessageAt: 'Today'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'm2',
|
||||||
|
unread: false,
|
||||||
|
otherUser: 'Sam',
|
||||||
|
preview: 'Search should stay in the top middle <20> full width.',
|
||||||
|
lastMessageAt: 'Monday'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const demoProfile = {
|
||||||
|
username: 'Joey',
|
||||||
|
usernamePretty: 'Joey',
|
||||||
|
gender: 'unspecified',
|
||||||
|
newsletter: true,
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
dateFormat: 'F-j-Y',
|
||||||
|
timeFormat: 'g:i:s A',
|
||||||
|
pageLimit: '10',
|
||||||
|
darkMode: false,
|
||||||
|
registered: 'September 2024',
|
||||||
|
lastLogin: 'Just now'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dateFormatOptions = [
|
||||||
|
{ label: 'January 8, 1991', value: 'F-j-Y' },
|
||||||
|
{ label: '8 January, 1991', value: 'j-F-Y' },
|
||||||
|
{ label: 'Jan 8, 1991', value: 'M-j-Y' },
|
||||||
|
{ label: '1-8-1991', value: 'n-j-Y' },
|
||||||
|
{ label: '01-08-1991', value: 'm-d-Y' },
|
||||||
|
{ label: '8-1-1991', value: 'j-n-Y' },
|
||||||
|
{ label: '08-01-1991', value: 'd-m-Y' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const timeFormatOptions = [
|
||||||
|
{ label: '3:33:33 AM', value: 'g:i:s A' },
|
||||||
|
{ label: '03:33:33 AM', value: 'h:i:s A' },
|
||||||
|
{ label: '3:33:33 (military)', value: 'G:i:s' },
|
||||||
|
{ label: '03:33:33 (military)', value: 'H:i:s' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const timezoneOptions = [
|
||||||
|
'America/New_York',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Denver',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/Phoenix',
|
||||||
|
'UTC',
|
||||||
|
'Europe/London',
|
||||||
|
'Europe/Paris'
|
||||||
|
]
|
||||||
|
|
||||||
|
export const pageLimitOptions = ['10', '15', '20', '25', '50']
|
||||||
|
|
||||||
|
export const searchResources = [
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'posts', label: 'Posts' },
|
||||||
|
{ value: 'pages', label: 'Pages' }
|
||||||
|
]
|
||||||
@ -1,10 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* Login and home views. The token never enters this process.
|
* Login, chrome, and in-app views. API lists are placeholders until the next step.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const loginView = document.getElementById('view-login')
|
import {
|
||||||
const homeView = document.getElementById('view-home')
|
dateFormatOptions,
|
||||||
const topStatus = document.getElementById('top-status')
|
demoMessages,
|
||||||
|
demoNotifications,
|
||||||
|
demoProfile,
|
||||||
|
pageLimitOptions,
|
||||||
|
previewSession,
|
||||||
|
timeFormatOptions,
|
||||||
|
timezoneOptions
|
||||||
|
} from './demo.js'
|
||||||
|
|
||||||
|
const views = {
|
||||||
|
login: document.getElementById('view-login'),
|
||||||
|
home: document.getElementById('view-home'),
|
||||||
|
stub: document.getElementById('view-stub'),
|
||||||
|
search: document.getElementById('view-search'),
|
||||||
|
notifications: document.getElementById('view-notifications'),
|
||||||
|
messages: document.getElementById('view-messages'),
|
||||||
|
profile: document.getElementById('view-profile'),
|
||||||
|
settings: document.getElementById('view-settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainPages = {
|
||||||
|
game: 'Game',
|
||||||
|
friends: 'Friends',
|
||||||
|
cabal: 'Cabal',
|
||||||
|
hiscores: 'HiScores',
|
||||||
|
shop: 'Shop'
|
||||||
|
}
|
||||||
|
|
||||||
const loginForm = document.getElementById('login-form')
|
const loginForm = document.getElementById('login-form')
|
||||||
const loginError = document.getElementById('login-error')
|
const loginError = document.getElementById('login-error')
|
||||||
const loginSubmit = document.getElementById('login-submit')
|
const loginSubmit = document.getElementById('login-submit')
|
||||||
@ -15,17 +42,13 @@ const tokenError = document.getElementById('token-error')
|
|||||||
const tokenSubmit = document.getElementById('token-submit')
|
const tokenSubmit = document.getElementById('token-submit')
|
||||||
const tokenSite = document.getElementById('token-site')
|
const tokenSite = document.getElementById('token-site')
|
||||||
const tokenUsername = document.getElementById('token-username')
|
const tokenUsername = document.getElementById('token-username')
|
||||||
const homeUsername = document.getElementById('home-username')
|
const previewButton = document.getElementById('preview-shell')
|
||||||
const homeSite = document.getElementById('home-site')
|
|
||||||
const homeUserId = document.getElementById('home-userid')
|
let session = null
|
||||||
const homeMethod = document.getElementById('home-method')
|
let profile = { ...demoProfile }
|
||||||
const homeApi = document.getElementById('home-api')
|
|
||||||
const homeNote = document.getElementById('home-note')
|
|
||||||
const openSite = document.getElementById('open-site')
|
|
||||||
const logoutButton = document.getElementById('logout-button')
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show or hide a status/error line.
|
* Show or hide a status line.
|
||||||
*
|
*
|
||||||
* @param {HTMLElement} el - message node
|
* @param {HTMLElement} el - message node
|
||||||
* @param {string} [message] - text to show; empty hides the node
|
* @param {string} [message] - text to show; empty hides the node
|
||||||
@ -38,61 +61,327 @@ function setMessage(el, message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fill both site URL fields from the last used host.
|
* Site path that opens in the system browser.
|
||||||
*
|
*
|
||||||
* @param {string} siteUrl - canonical site base
|
* @param {string} path - path after the site root
|
||||||
* @return {void}
|
* @return {string} - absolute URL, or #
|
||||||
*/
|
*/
|
||||||
function fillSiteFields(siteUrl) {
|
function siteHref(path) {
|
||||||
if (!siteUrl) {
|
if (!session?.siteUrl) {
|
||||||
return
|
return '#'
|
||||||
}
|
}
|
||||||
loginSite.value = siteUrl
|
return `${session.siteUrl.replace(/\/+$/, '')}/${String(path).replace(/^\/+/, '')}`
|
||||||
tokenSite.value = siteUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the login or home view from a public session.
|
* Avatar URL for the connected site, or empty when none.
|
||||||
*
|
*
|
||||||
* @param {object} session - connection state from main
|
* @return {string} - image URL
|
||||||
* @return {void}
|
|
||||||
*/
|
*/
|
||||||
function render(session) {
|
function avatarUrl() {
|
||||||
const connected = Boolean(session?.connected)
|
if (!session?.siteUrl) {
|
||||||
loginView.hidden = connected
|
return ''
|
||||||
homeView.hidden = !connected
|
}
|
||||||
fillSiteFields(session?.lastSiteUrl || session?.siteUrl || '')
|
return `${session.siteUrl.replace(/\/+$/, '')}/images/defaultAvatar.png`
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
topStatus.hidden = true
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = session.username || 'API token'
|
/**
|
||||||
homeUsername.textContent = session.username ? username : 'Signed in with token'
|
* Logo on navy chrome.
|
||||||
homeSite.textContent = session.siteUrl
|
*
|
||||||
homeUserId.textContent = session.userId == null ? '<27>' : String(session.userId)
|
* @return {string} - image URL
|
||||||
homeMethod.textContent = session.authMethod === 'token' ? 'API token' : 'Password'
|
*/
|
||||||
homeApi.textContent = session.apiReady ? 'Ready' : 'Limited'
|
function logoUrl() {
|
||||||
openSite.href = session.siteUrl
|
if (!session?.siteUrl) {
|
||||||
topStatus.hidden = false
|
return ''
|
||||||
topStatus.classList.add('is-ok')
|
}
|
||||||
topStatus.textContent = 'Connected'
|
return `${session.siteUrl.replace(/\/+$/, '')}/images/logoWhite.png`
|
||||||
|
}
|
||||||
|
|
||||||
if (session.apiReady) {
|
/**
|
||||||
setMessage(homeNote, '')
|
* Fill a select from label/value pairs or plain strings.
|
||||||
} else {
|
*
|
||||||
setMessage(
|
* @param {HTMLSelectElement} select - target
|
||||||
homeNote,
|
* @param {Array<string|{label: string, value: string}>} options - choices
|
||||||
'Signed in, but api/users/find did not confirm this user. Turn on personal API access on the site, or add a username when connecting with a token.'
|
* @param {string} selected - current value
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function fillSelect(select, options, selected) {
|
||||||
|
select.innerHTML = ''
|
||||||
|
options.forEach((option) => {
|
||||||
|
const value = typeof option === 'string' ? option : option.value
|
||||||
|
const label = typeof option === 'string' ? option : option.label
|
||||||
|
const node = document.createElement('option')
|
||||||
|
node.value = value
|
||||||
|
node.textContent = label
|
||||||
|
if (value === selected) {
|
||||||
|
node.selected = true
|
||||||
|
}
|
||||||
|
select.appendChild(node)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paint header identity, badges, and site links.
|
||||||
|
*
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function renderChrome() {
|
||||||
|
const connected = Boolean(session?.connected)
|
||||||
|
document.body.dataset.shell = connected ? 'app' : 'login'
|
||||||
|
document.getElementById('header-username').textContent = session?.username || 'Account'
|
||||||
|
|
||||||
|
const logo = document.getElementById('header-logo')
|
||||||
|
const avatar = document.getElementById('header-avatar')
|
||||||
|
const avatarFallback = document.getElementById('header-avatar-fallback')
|
||||||
|
const nextLogo = logoUrl()
|
||||||
|
const nextAvatar = avatarUrl()
|
||||||
|
|
||||||
|
logo.onerror = () => {
|
||||||
|
logo.hidden = true
|
||||||
|
}
|
||||||
|
avatar.onerror = () => {
|
||||||
|
avatar.hidden = true
|
||||||
|
avatarFallback.hidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
logo.hidden = !nextLogo
|
||||||
|
if (nextLogo) {
|
||||||
|
logo.src = nextLogo
|
||||||
|
}
|
||||||
|
|
||||||
|
avatar.hidden = !nextAvatar
|
||||||
|
avatarFallback.hidden = Boolean(nextAvatar)
|
||||||
|
if (nextAvatar) {
|
||||||
|
avatar.src = nextAvatar
|
||||||
|
document.getElementById('profile-avatar').src = nextAvatar
|
||||||
|
document.getElementById('settings-avatar').src = nextAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = siteHref('usercp/email')
|
||||||
|
const password = siteHref('usercp/password')
|
||||||
|
const phone = siteHref('usercp/phone')
|
||||||
|
document.getElementById('link-email').href = email
|
||||||
|
document.getElementById('link-password').href = password
|
||||||
|
document.getElementById('link-phone').href = phone
|
||||||
|
document.getElementById('settings-link-email').href = email
|
||||||
|
document.getElementById('settings-link-password').href = password
|
||||||
|
document.getElementById('settings-link-phone').href = phone
|
||||||
|
|
||||||
|
const unreadNotes = demoNotifications.filter((item) => item.unread).length
|
||||||
|
const unreadMail = demoMessages.filter((item) => item.unread).length
|
||||||
|
const noteBadge = document.getElementById('notification-badge')
|
||||||
|
const mailBadge = document.getElementById('message-badge')
|
||||||
|
noteBadge.hidden = unreadNotes === 0
|
||||||
|
mailBadge.hidden = unreadMail === 0
|
||||||
|
noteBadge.textContent = String(unreadNotes)
|
||||||
|
mailBadge.textContent = String(unreadMail)
|
||||||
|
|
||||||
|
fillDropdown(
|
||||||
|
document.getElementById('notification-menu'),
|
||||||
|
demoNotifications
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li data-demo-item>
|
||||||
|
<a href="#/notifications" class="dropdown-item dropdown-item-block" data-route="notifications">
|
||||||
|
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${item.createdAt}</p>
|
||||||
|
<span>${item.html}</span>
|
||||||
|
</a>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
)
|
||||||
|
|
||||||
|
fillDropdown(
|
||||||
|
document.getElementById('message-menu'),
|
||||||
|
demoMessages
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<li data-demo-item>
|
||||||
|
<a href="#/messages" class="dropdown-item dropdown-item-block" data-route="messages">
|
||||||
|
<strong>${item.otherUser}</strong>
|
||||||
|
<p class="small text-muted mb-1"><i class="fa fa-fw fa-clock me-1"></i>${item.lastMessageAt}</p>
|
||||||
|
<span>${item.preview}</span>
|
||||||
|
</a>
|
||||||
|
</li>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace demo rows at the top of a header dropdown.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} list - dropdown ul
|
||||||
|
* @param {string} html - li markup
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function fillDropdown(list, html) {
|
||||||
|
list.querySelectorAll('[data-demo-item]').forEach((node) => node.remove())
|
||||||
|
list.insertAdjacentHTML('afterbegin', html)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an auth IPC call and render the result or an error.
|
* Fill the in-app pages from demo (and later API) data.
|
||||||
*
|
*
|
||||||
* @param {HTMLButtonElement} button - submit button to disable
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function renderPages() {
|
||||||
|
if (!session?.connected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('home-username').textContent = session.username || 'this account'
|
||||||
|
const siteLink = document.getElementById('home-site')
|
||||||
|
siteLink.textContent = session.siteUrl
|
||||||
|
siteLink.href = session.siteUrl
|
||||||
|
|
||||||
|
document.getElementById('notification-list').innerHTML = demoNotifications
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr class="${item.unread ? 'is-unread' : ''}">
|
||||||
|
<td>${item.html}</td>
|
||||||
|
<td class="text-muted text-nowrap">${item.createdAt}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" disabled title="Mark as read">
|
||||||
|
<i class="fa-solid fa-fw fa-envelope-open"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
document.getElementById('message-list').innerHTML = demoMessages
|
||||||
|
.map(
|
||||||
|
(item) => `
|
||||||
|
<tr class="${item.unread ? 'is-unread' : ''}">
|
||||||
|
<td>${item.otherUser}</td>
|
||||||
|
<td>${item.preview}</td>
|
||||||
|
<td class="text-muted">${item.lastMessageAt}</td>
|
||||||
|
</tr>`
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
profile.username = session.username || profile.username
|
||||||
|
profile.usernamePretty = profile.username
|
||||||
|
document.getElementById('profile-name').textContent = profile.usernamePretty
|
||||||
|
document.getElementById('profile-registered').textContent = profile.registered
|
||||||
|
document.getElementById('profile-last-login').textContent = profile.lastLogin
|
||||||
|
document.getElementById('profile-gender').textContent = profile.gender
|
||||||
|
|
||||||
|
document.getElementById('settings-gender').value = profile.gender
|
||||||
|
document.getElementById('settings-newsletter').checked = profile.newsletter
|
||||||
|
document.getElementById('settings-dark').checked = profile.darkMode
|
||||||
|
fillSelect(document.getElementById('settings-timezone'), timezoneOptions, profile.timezone)
|
||||||
|
fillSelect(document.getElementById('settings-date'), dateFormatOptions, profile.dateFormat)
|
||||||
|
fillSelect(document.getElementById('settings-time'), timeFormatOptions, profile.timeFormat)
|
||||||
|
fillSelect(document.getElementById('settings-limit'), pageLimitOptions, profile.pageLimit)
|
||||||
|
applyTheme(profile.darkMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle TTP light/dark tokens on the document.
|
||||||
|
*
|
||||||
|
* @param {boolean} dark - dark-mode preference
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function applyTheme(dark) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', dark ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show one named view.
|
||||||
|
*
|
||||||
|
* @param {string} name - views key
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function showView(name) {
|
||||||
|
Object.entries(views).forEach(([key, node]) => {
|
||||||
|
node.hidden = key !== name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the matching main-nav link, or none.
|
||||||
|
*
|
||||||
|
* @param {string} path - hash path without query
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function setMainNav(path) {
|
||||||
|
document.querySelectorAll('.capsule-mainnav [data-nav]').forEach((link) => {
|
||||||
|
link.classList.toggle('active', link.dataset.nav === path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route from the hash. Login when there is no session.
|
||||||
|
*
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function route() {
|
||||||
|
if (!session?.connected) {
|
||||||
|
showView('login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash.replace(/^#\/?/, '')
|
||||||
|
const [path] = hash.split('?')
|
||||||
|
setMainNav(path)
|
||||||
|
|
||||||
|
if (mainPages[path]) {
|
||||||
|
document.getElementById('stub-title').textContent = mainPages[path]
|
||||||
|
showView('stub')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === 'notifications') {
|
||||||
|
showView('notifications')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === 'messages') {
|
||||||
|
showView('messages')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === 'profile') {
|
||||||
|
showView('profile')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === 'settings') {
|
||||||
|
showView('settings')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (path === 'search') {
|
||||||
|
showView('search')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showView('home')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a public session and go to the current route.
|
||||||
|
*
|
||||||
|
* @param {object} next - connection state
|
||||||
|
* @return {void}
|
||||||
|
*/
|
||||||
|
function applySession(next) {
|
||||||
|
session = next
|
||||||
|
if (!session?.connected) {
|
||||||
|
window.location.hash = ''
|
||||||
|
applyTheme(false)
|
||||||
|
} else if (!window.location.hash) {
|
||||||
|
window.location.hash = '#/'
|
||||||
|
}
|
||||||
|
if (loginSite && session?.lastSiteUrl) {
|
||||||
|
loginSite.value = session.lastSiteUrl
|
||||||
|
tokenSite.value = session.lastSiteUrl
|
||||||
|
}
|
||||||
|
renderChrome()
|
||||||
|
renderPages()
|
||||||
|
route()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an auth IPC call.
|
||||||
|
*
|
||||||
|
* @param {HTMLButtonElement} button - submit button
|
||||||
* @param {HTMLElement} errorEl - error line
|
* @param {HTMLElement} errorEl - error line
|
||||||
* @param {() => Promise<object>} work - IPC call
|
* @param {() => Promise<object>} work - IPC call
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
@ -101,7 +390,7 @@ async function runAuth(button, errorEl, work) {
|
|||||||
button.disabled = true
|
button.disabled = true
|
||||||
setMessage(errorEl, '')
|
setMessage(errorEl, '')
|
||||||
try {
|
try {
|
||||||
render(await work())
|
applySession(await work())
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setMessage(errorEl, err?.message || 'Sign-in failed.')
|
setMessage(errorEl, err?.message || 'Sign-in failed.')
|
||||||
} finally {
|
} finally {
|
||||||
@ -111,6 +400,10 @@ async function runAuth(button, errorEl, work) {
|
|||||||
|
|
||||||
loginForm.addEventListener('submit', (event) => {
|
loginForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!window.capsule) {
|
||||||
|
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
|
||||||
|
return
|
||||||
|
}
|
||||||
runAuth(loginSubmit, loginError, () =>
|
runAuth(loginSubmit, loginError, () =>
|
||||||
window.capsule.login({
|
window.capsule.login({
|
||||||
siteUrl: loginSite.value,
|
siteUrl: loginSite.value,
|
||||||
@ -122,6 +415,10 @@ loginForm.addEventListener('submit', (event) => {
|
|||||||
|
|
||||||
tokenForm.addEventListener('submit', (event) => {
|
tokenForm.addEventListener('submit', (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
if (!window.capsule) {
|
||||||
|
setMessage(tokenError, 'Preload bridge is missing. Restart Capsule.')
|
||||||
|
return
|
||||||
|
}
|
||||||
runAuth(tokenSubmit, tokenError, () =>
|
runAuth(tokenSubmit, tokenError, () =>
|
||||||
window.capsule.connectToken({
|
window.capsule.connectToken({
|
||||||
siteUrl: tokenSite.value,
|
siteUrl: tokenSite.value,
|
||||||
@ -131,36 +428,85 @@ tokenForm.addEventListener('submit', (event) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
logoutButton.addEventListener('click', async () => {
|
previewButton.addEventListener('click', () => {
|
||||||
render(await window.capsule.logout())
|
applySession({ ...previewSession })
|
||||||
})
|
})
|
||||||
|
|
||||||
openSite.addEventListener('click', (event) => {
|
document.getElementById('logout-button').addEventListener('click', async () => {
|
||||||
const href = openSite.getAttribute('href')
|
if (session?.preview || !window.capsule) {
|
||||||
if (!href || href === '#') {
|
applySession({ connected: false, lastSiteUrl: session?.siteUrl || '' })
|
||||||
event.preventDefault()
|
return
|
||||||
}
|
}
|
||||||
|
applySession(await window.capsule.logout())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
document.getElementById('header-search').addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
const q = document.getElementById('search-q').value.trim()
|
||||||
|
const resource = document.getElementById('search-resource').value
|
||||||
|
const params = new URLSearchParams({ q, resource })
|
||||||
|
document.getElementById('search-summary').textContent = q
|
||||||
|
? `Searching <20>${q}<EFBFBD> in ${resource}.`
|
||||||
|
: `Choose a term to search ${resource}.`
|
||||||
|
window.location.hash = `#/search?${params.toString()}`
|
||||||
|
route()
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('settings-form').addEventListener('submit', (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
profile.gender = document.getElementById('settings-gender').value
|
||||||
|
profile.newsletter = document.getElementById('settings-newsletter').checked
|
||||||
|
profile.timezone = document.getElementById('settings-timezone').value
|
||||||
|
profile.dateFormat = document.getElementById('settings-date').value
|
||||||
|
profile.timeFormat = document.getElementById('settings-time').value
|
||||||
|
profile.pageLimit = document.getElementById('settings-limit').value
|
||||||
|
profile.darkMode = document.getElementById('settings-dark').checked
|
||||||
|
applyTheme(profile.darkMode)
|
||||||
|
document.getElementById('profile-gender').textContent = profile.gender
|
||||||
|
setMessage(
|
||||||
|
document.getElementById('settings-status'),
|
||||||
|
'Saved in Capsule for now. The site API will persist this next.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('settings-avatar-file').addEventListener('change', (event) => {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
document.getElementById('settings-avatar').src = url
|
||||||
|
document.getElementById('profile-avatar').src = url
|
||||||
|
document.getElementById('header-avatar').src = url
|
||||||
|
document.getElementById('header-avatar').hidden = false
|
||||||
|
document.getElementById('header-avatar-fallback').hidden = true
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('settings-dark').addEventListener('change', (event) => {
|
||||||
|
applyTheme(event.target.checked)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('hashchange', route)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load any stored session, then confirm it against the site when possible.
|
* Load a stored session, or stay on login.
|
||||||
*
|
*
|
||||||
* @return {Promise<void>}
|
* @return {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async function boot() {
|
async function boot() {
|
||||||
if (!window.capsule) {
|
if (!window.capsule) {
|
||||||
setMessage(loginError, 'Preload bridge is missing. Restart Capsule.')
|
applySession({ connected: false })
|
||||||
loginView.hidden = false
|
setMessage(loginError, '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await window.capsule.session()
|
const stored = await window.capsule.session()
|
||||||
if (session.connected) {
|
if (stored.connected) {
|
||||||
render(await window.capsule.verify())
|
applySession(await window.capsule.verify())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
render(session)
|
applySession(stored)
|
||||||
}
|
}
|
||||||
|
|
||||||
boot()
|
boot()
|
||||||
|
|||||||
@ -1,314 +1,303 @@
|
|||||||
/**
|
/**
|
||||||
* Capsule desktop chrome. Tokens follow the TTP brand (logo #3fa9f5, chrome #0c1929).
|
* Capsule chrome. Tokens and header paint follow TTP main.css / main-dark.css.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: light;
|
||||||
--canvas: #08111c;
|
--ttp-canvas: #e8eef5;
|
||||||
--chrome: #0c1929;
|
--ttp-surface: #ffffff;
|
||||||
--surface: #122033;
|
--ttp-surface-alt: #f1f5fa;
|
||||||
--surface-alt: #173049;
|
--ttp-surface-muted: #dfe7f0;
|
||||||
--text: #e8eef5;
|
--ttp-surface-inset: #f7f9fc;
|
||||||
--muted: #8aa0b5;
|
--ttp-text: #122033;
|
||||||
--border: #274056;
|
--ttp-text-muted: #5c6e82;
|
||||||
--brand: #3fa9f5;
|
--ttp-border: #d0dbe6;
|
||||||
--primary: #1784c9;
|
--ttp-border-strong: #122033;
|
||||||
--primary-hover: #146ea8;
|
--ttp-link: #1577b8;
|
||||||
--danger: #f07178;
|
--ttp-link-hover: #0f5f94;
|
||||||
--ok: #3dd68c;
|
--ttp-input-bg: #ffffff;
|
||||||
--focus-ring: rgba(63, 169, 245, 0.35);
|
--ttp-input-border: #c5d3e0;
|
||||||
--shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
|
--ttp-input-text: #122033;
|
||||||
--radius: 0.85rem;
|
--ttp-focus: #3fa9f5;
|
||||||
--font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
--ttp-focus-ring: rgba(63, 169, 245, 0.35);
|
||||||
|
--ttp-brand: #3fa9f5;
|
||||||
|
--ttp-primary: #1784c9;
|
||||||
|
--ttp-primary-hover: #146ea8;
|
||||||
|
--ttp-primary-active: #115a8a;
|
||||||
|
--ttp-primary-rgb: 23, 132, 201;
|
||||||
|
--ttp-chrome: #0c1929;
|
||||||
|
--ttp-chrome-rgb: 12, 25, 41;
|
||||||
|
--ttp-radius: 0.85rem;
|
||||||
|
--ttp-radius-sm: 0.5rem;
|
||||||
|
--ttp-shadow: 0 1px 2px rgba(18, 32, 51, 0.05), 0 10px 28px rgba(18, 32, 51, 0.07);
|
||||||
|
--ttp-shadow-lg: 0 16px 40px rgba(18, 32, 51, 0.14);
|
||||||
|
--ttp-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
--bs-body-color: var(--ttp-text);
|
||||||
|
--bs-body-bg: var(--ttp-canvas);
|
||||||
|
--bs-primary: var(--ttp-primary);
|
||||||
|
--bs-primary-rgb: var(--ttp-primary-rgb);
|
||||||
|
--bs-dark: var(--ttp-chrome);
|
||||||
|
--bs-dark-rgb: var(--ttp-chrome-rgb);
|
||||||
|
--bs-link-color: var(--ttp-link);
|
||||||
|
--bs-link-hover-color: var(--ttp-link-hover);
|
||||||
|
--bs-border-color: var(--ttp-border);
|
||||||
|
--bs-border-radius: var(--ttp-radius);
|
||||||
|
--bs-body-font-family: var(--ttp-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
html[data-bs-theme='dark'] {
|
||||||
box-sizing: border-box;
|
color-scheme: dark;
|
||||||
|
--ttp-canvas: #0b1220;
|
||||||
|
--ttp-surface: #151d2c;
|
||||||
|
--ttp-surface-alt: #1c2638;
|
||||||
|
--ttp-surface-muted: #243044;
|
||||||
|
--ttp-surface-inset: #0f1623;
|
||||||
|
--ttp-text: #e9eef6;
|
||||||
|
--ttp-text-muted: #9aaabb;
|
||||||
|
--ttp-border: #2c3a50;
|
||||||
|
--ttp-border-strong: #e9eef6;
|
||||||
|
--ttp-link: #6eb6ff;
|
||||||
|
--ttp-link-hover: #9ccfff;
|
||||||
|
--ttp-input-bg: #0f1623;
|
||||||
|
--ttp-input-border: #3a4b64;
|
||||||
|
--ttp-input-text: #e9eef6;
|
||||||
|
--ttp-shadow: 0 1px 2px rgba(0, 0, 0, 0.35), 0 12px 32px rgba(0, 0, 0, 0.28);
|
||||||
|
--ttp-shadow-lg: 0 18px 44px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: var(--canvas);
|
margin: 0;
|
||||||
color: var(--text);
|
background: var(--ttp-canvas);
|
||||||
font-family: var(--font);
|
color: var(--ttp-text);
|
||||||
|
font-family: var(--ttp-font);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
.context-main-bg {
|
||||||
background:
|
background: var(--ttp-surface);
|
||||||
radial-gradient(900px 420px at 10% -10%, rgba(63, 169, 245, 0.16), transparent 55%),
|
color: var(--ttp-text);
|
||||||
radial-gradient(700px 360px at 100% 0%, rgba(23, 132, 201, 0.12), transparent 50%),
|
|
||||||
var(--canvas);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
header.text-bg-dark {
|
||||||
min-height: 100vh;
|
background-color: var(--ttp-chrome) !important;
|
||||||
display: flex;
|
box-shadow: 0 1px 0 rgba(63, 169, 245, 0.42), 0 10px 28px rgba(12, 25, 41, 0.18);
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.capsule-header {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 1.4rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: rgba(12, 25, 41, 0.86);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.capsule-brand {
|
||||||
display: flex;
|
gap: 0.65rem;
|
||||||
align-items: center;
|
min-width: 8.5rem;
|
||||||
gap: 0.75rem;
|
}
|
||||||
|
|
||||||
|
.capsule-logo {
|
||||||
|
border-radius: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 1.15rem;
|
height: 1.15rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: linear-gradient(135deg, #7ec8f8, var(--brand) 55%, #1784c9);
|
background: linear-gradient(135deg, #7ec8f8, var(--ttp-brand) 55%, #1784c9);
|
||||||
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12), 0 8px 18px rgba(63, 169, 245, 0.25);
|
box-shadow: 0 0 0 4px rgba(63, 169, 245, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name,
|
.capsule-logo:not([hidden]) + .brand-mark {
|
||||||
.brand-tag,
|
display: none;
|
||||||
.eyebrow,
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
p,
|
|
||||||
dt,
|
|
||||||
dd,
|
|
||||||
label span,
|
|
||||||
button,
|
|
||||||
summary,
|
|
||||||
li {
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-name {
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-tag,
|
.capsule-search {
|
||||||
.eyebrow,
|
width: 100%;
|
||||||
.hint,
|
justify-self: stretch;
|
||||||
.lede,
|
|
||||||
.meta dt,
|
|
||||||
.top-status {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-tag,
|
.capsule-search fieldset {
|
||||||
.eyebrow {
|
border: 0;
|
||||||
font-size: 0.75rem;
|
margin: 0;
|
||||||
letter-spacing: 0.08em;
|
padding: 0;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-status {
|
.capsule-search .input-group {
|
||||||
font-size: 0.85rem;
|
flex-wrap: nowrap;
|
||||||
|
border-radius: var(--ttp-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-status.is-ok {
|
.capsule-search .input-group > :first-child {
|
||||||
color: var(--ok);
|
border-top-left-radius: var(--ttp-radius-sm);
|
||||||
|
border-bottom-left-radius: var(--ttp-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage {
|
.capsule-search .input-group > :last-child {
|
||||||
flex: 1;
|
border-top-right-radius: var(--ttp-radius-sm);
|
||||||
|
border-bottom-right-radius: var(--ttp-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-account {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-shell='login'] .capsule-search,
|
||||||
|
body[data-shell='login'] .capsule-account,
|
||||||
|
body[data-shell='login'] .capsule-mainnav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-mainnav {
|
||||||
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1.5rem;
|
padding: 0 1rem 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.view {
|
.capsule-mainnav-list {
|
||||||
width: min(920px, 100%);
|
display: flex;
|
||||||
}
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.card {
|
|
||||||
background: rgba(18, 32, 51, 0.92);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
padding: 1.6rem 1.7rem 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card h1,
|
|
||||||
.card h2 {
|
|
||||||
margin: 0.35rem 0 0.7rem;
|
|
||||||
font-size: 1.55rem;
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lede {
|
|
||||||
line-height: 1.55;
|
|
||||||
margin-bottom: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field span {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.field em {
|
|
||||||
font-style: normal;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
width: 100%;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: #0c1929;
|
|
||||||
color: var(--text);
|
|
||||||
border-radius: 0.55rem;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--brand);
|
|
||||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid transparent;
|
gap: 0.15rem;
|
||||||
border-radius: 0.55rem;
|
list-style: none;
|
||||||
padding: 0.65rem 0.9rem;
|
margin: 0;
|
||||||
font: inherit;
|
padding: 0;
|
||||||
font-weight: 600;
|
}
|
||||||
cursor: pointer;
|
|
||||||
|
.capsule-mainnav a {
|
||||||
|
display: block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.capsule-mainnav a:hover,
|
||||||
opacity: 0.6;
|
.capsule-mainnav a:focus,
|
||||||
cursor: wait;
|
.capsule-mainnav a.active {
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary);
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
background-color: rgba(63, 169, 245, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.capsule-mainnav a.active {
|
||||||
background: var(--primary-hover);
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
body[data-shell='login'] .capsule-header {
|
||||||
background: var(--surface-alt);
|
grid-template-columns: auto;
|
||||||
color: var(--text);
|
justify-content: start;
|
||||||
border-color: var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
header .dropdown {
|
||||||
background: transparent;
|
position: relative;
|
||||||
color: var(--brand);
|
flex-shrink: 0;
|
||||||
border-color: var(--border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
header .dropdown-toggle {
|
||||||
color: var(--danger);
|
outline: 0;
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel {
|
header .dropdown-menu.dropdown-menu-dark {
|
||||||
margin-top: 1.2rem;
|
--bs-dropdown-min-width: 16rem;
|
||||||
border-top: 1px solid var(--border);
|
--bs-dropdown-bg: #0c1929;
|
||||||
padding-top: 0.9rem;
|
--bs-dropdown-color: #fff;
|
||||||
|
--bs-dropdown-border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
--bs-dropdown-link-color: rgba(255, 255, 255, 0.9);
|
||||||
|
--bs-dropdown-link-hover-color: #fff;
|
||||||
|
--bs-dropdown-link-hover-bg: rgba(63, 169, 245, 0.18);
|
||||||
|
--bs-dropdown-link-active-bg: var(--ttp-primary);
|
||||||
|
--bs-dropdown-divider-bg: rgba(255, 255, 255, 0.12);
|
||||||
|
background-color: #0c1929 !important;
|
||||||
|
z-index: 1050;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel summary {
|
header .form-control,
|
||||||
cursor: pointer;
|
header .form-select {
|
||||||
color: var(--brand);
|
background-color: var(--ttp-input-bg);
|
||||||
|
border-color: var(--ttp-input-border);
|
||||||
|
color: var(--ttp-input-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
header .form-control:focus,
|
||||||
|
header .form-select:focus {
|
||||||
|
border-color: var(--ttp-focus);
|
||||||
|
box-shadow: 0 0 0 0.2rem var(--ttp-focus-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.rounded-pill {
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
vertical-align: super;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
--bs-card-bg: var(--ttp-surface);
|
||||||
|
--bs-card-color: var(--ttp-text);
|
||||||
|
--bs-card-border-color: var(--ttp-border);
|
||||||
|
--bs-card-border-radius: var(--ttp-radius);
|
||||||
|
box-shadow: var(--ttp-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header.bg-dark {
|
||||||
|
background-color: var(--ttp-chrome) !important;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
--bs-table-bg: var(--ttp-surface);
|
||||||
|
--bs-table-color: var(--ttp-text);
|
||||||
|
--bs-table-border-color: var(--ttp-border);
|
||||||
|
--bs-table-striped-bg: var(--ttp-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--ttp-input-bg);
|
||||||
|
color: var(--ttp-input-text);
|
||||||
|
border-color: var(--ttp-input-border);
|
||||||
|
border-radius: var(--ttp-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.capsule-login-card {
|
||||||
|
max-width: 28rem;
|
||||||
|
box-shadow: var(--ttp-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item-block {
|
||||||
|
display: block;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-unread {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.token-panel .form {
|
@media (max-width: 900px) {
|
||||||
margin-top: 0.9rem;
|
.capsule-header {
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'brand account'
|
||||||
|
'search search';
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.capsule-brand {
|
||||||
font-size: 0.88rem;
|
grid-area: brand;
|
||||||
line-height: 1.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint code {
|
.capsule-account {
|
||||||
font-size: 0.84em;
|
grid-area: account;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-grid {
|
.capsule-search {
|
||||||
display: grid;
|
grid-area: search;
|
||||||
grid-template-columns: minmax(240px, 0.9fr) minmax(280px, 1.1fr);
|
width: 100%;
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.identity h1 {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-line {
|
|
||||||
color: var(--brand);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 0.7rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta dt {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta dd {
|
|
||||||
margin: 0.2rem 0 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1.1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan li::marker {
|
|
||||||
color: var(--brand);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 800px) {
|
|
||||||
.home-grid,
|
|
||||||
.meta {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user