Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64be9257f2 | ||
|
|
0022f64c50 | ||
|
|
d2f7b98e6c | ||
|
|
8020ddc787 | ||
|
|
87ce038b19 | ||
|
|
ccba2932c2 | ||
|
|
f2a33a9164 | ||
|
|
41272bd9ce | ||
|
|
d31e186bd9 | ||
|
|
0ac0e34012 |
No files matched your search
@@ -23,6 +23,22 @@ Open `http://localhost:8080` and set your password on first visit.
|
|||||||
|
|
||||||
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
The `-v hyprcrd-data:/var/www/html/data` flag persists the database across container restarts.
|
||||||
|
|
||||||
|
### Via docker compose
|
||||||
|
|
||||||
|
If you prefer compose, the bundled `docker-compose.yml` starts the app alone by default:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d # just the app on :8088
|
||||||
|
```
|
||||||
|
|
||||||
|
To also bring up a local relay (for multi-tenant messaging / collab / chat), opt in via the `relay` profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile relay up -d # app on :8088 + relay on :8081
|
||||||
|
```
|
||||||
|
|
||||||
|
Most installs talk to a remote relay, so the app-only default keeps the local setup minimal.
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|
||||||
### 1. Clone
|
### 1. Clone
|
||||||
@@ -418,6 +434,8 @@ access via `.htaccess`).
|
|||||||
|
|
||||||
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
Starts hyprcrd on `http://localhost:8088` and the relay on `http://localhost:8081` in the same Docker network. Source files are bind-mounted — changes are live without rebuilding. From inside the hyprcrd container, the relay is reachable at `http://relay:80`.
|
||||||
|
|
||||||
|
Under the hood this runs `docker compose --profile relay up --build`. The relay service lives behind the `relay` compose profile, so a plain `docker compose up` brings up only the app — handy when you're developing against a remote relay.
|
||||||
|
|
||||||
### Setup workflow
|
### Setup workflow
|
||||||
|
|
||||||
1. Open the relay URL **from a desktop browser** — the setup wizard
|
1. Open the relay URL **from a desktop browser** — the setup wizard
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.9.5');
|
define('APP_VERSION', '1.9.12');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Start hyprcrd + relay for local development
|
# Start hyprcrd + relay for local development. The relay service is
|
||||||
|
# behind a compose profile since it's opt-in — this script explicitly
|
||||||
|
# opts in so you get both containers.
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
docker compose up -d --build
|
docker compose --profile relay up -d --build
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " hyprcrd: http://localhost:8088"
|
echo " hyprcrd: http://localhost:8088"
|
||||||
@@ -11,3 +13,5 @@ echo " relay: http://localhost:8081"
|
|||||||
echo ""
|
echo ""
|
||||||
echo " relay from hyprcrd container: http://relay:80"
|
echo " relay from hyprcrd container: http://relay:80"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " (for app-only, drop the relay profile: docker compose up -d)"
|
||||||
|
echo ""
|
||||||
@@ -9,8 +9,12 @@ services:
|
|||||||
- ./public:/var/www/html/public
|
- ./public:/var/www/html/public
|
||||||
- ./config.php:/var/www/html/config.php
|
- ./config.php:/var/www/html/config.php
|
||||||
|
|
||||||
|
# Relay is opt-in via the `relay` profile. Plain `docker compose up`
|
||||||
|
# starts just the app; use `docker compose --profile relay up` when
|
||||||
|
# you want to develop against a local relay instead of a remote one.
|
||||||
relay:
|
relay:
|
||||||
build: ./relay
|
build: ./relay
|
||||||
|
profiles: ["relay"]
|
||||||
ports:
|
ports:
|
||||||
- "8081:80"
|
- "8081:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hyprcrd",
|
"name": "hyprcrd",
|
||||||
"version": "1.9.5",
|
"version": "1.9.12",
|
||||||
"description": "hyprcrd — self-hosted card manager",
|
"description": "hyprcrd — self-hosted card manager",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -780,6 +780,50 @@ body.detail-image-fs-open { overflow: hidden; }
|
|||||||
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
background: color-mix(in srgb, var(--t-accent) 12%, transparent) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bucket bar: no visible scrollbar, soft edge fades when the row
|
||||||
|
overflows so the user knows there's more off-screen. Fade is
|
||||||
|
implemented via absolutely-positioned overlay pseudo-elements on
|
||||||
|
#bucket-row (parent) rather than mask-image on the bar itself,
|
||||||
|
because mask-image would also clip super-bucket popovers rendered
|
||||||
|
inside the bar. Overlays use pointer-events:none so clicks pass
|
||||||
|
through to the tabs underneath. */
|
||||||
|
#bucket-bar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
#bucket-bar::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
#bucket-row::before,
|
||||||
|
#bucket-row::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
#bucket-row::before {
|
||||||
|
left: 24px; /* matches .px-6 = 24px on #bucket-row */
|
||||||
|
background: linear-gradient(to right, var(--t-bg) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
#bucket-row::after {
|
||||||
|
/* Stop at the right edge of #bucket-bar (before the toolbar
|
||||||
|
toggles). The toolbar is flex-0, bar is flex-1 — the easiest
|
||||||
|
way is to inset the overlay by the total width of the toolbar
|
||||||
|
via a right offset that the JS writes. Defaults to 24px (the
|
||||||
|
.px-6 pad) and the JS overrides with the real value. */
|
||||||
|
right: var(--bucket-row-right-inset, 24px);
|
||||||
|
background: linear-gradient(to left, var(--t-bg) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
#bucket-row.fade-left::before,
|
||||||
|
#bucket-row.fade-both::before { opacity: 1; }
|
||||||
|
#bucket-row.fade-right::after,
|
||||||
|
#bucket-row.fade-both::after { opacity: 1; }
|
||||||
|
|
||||||
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
|
/* Unread dot on the fixed `all` bucket-tab — appears when new cards
|
||||||
arrive while the user is viewing a different bucket. Cleared the
|
arrive while the user is viewing a different bucket. Cleared the
|
||||||
moment the user navigates back to all (renderBoard drains the
|
moment the user navigates back to all (renderBoard drains the
|
||||||
|
|||||||
+8
-2
@@ -45,6 +45,7 @@ window.openFindBar = openFindBar;
|
|||||||
window._reloadAll = async () => {
|
window._reloadAll = async () => {
|
||||||
await loadBuckets();
|
await loadBuckets();
|
||||||
state.cards = await api('api/cards') || [];
|
state.cards = await api('api/cards') || [];
|
||||||
|
state.cardsLoaded = true;
|
||||||
renderBoard();
|
renderBoard();
|
||||||
refreshCalendar();
|
refreshCalendar();
|
||||||
// Let plugins / modules react to a full card reload (detail view checks
|
// Let plugins / modules react to a full card reload (detail view checks
|
||||||
@@ -86,10 +87,14 @@ function setCachedCards(cards) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadCards() {
|
async function loadCards() {
|
||||||
// Phase 0: Show cached cards instantly (from previous session)
|
// Phase 0: Show cached cards instantly (from previous session).
|
||||||
|
// A hit here is enough to flip cardsLoaded — fresh meta will
|
||||||
|
// overwrite state.cards a moment later, but the spinner→content
|
||||||
|
// transition already happened.
|
||||||
const cached = getCachedCards();
|
const cached = getCachedCards();
|
||||||
if (cached.length) {
|
if (cached.length) {
|
||||||
state.cards = cached.map(c => ({ ...c, body: null }));
|
state.cards = cached.map(c => ({ ...c, body: null }));
|
||||||
|
state.cardsLoaded = true;
|
||||||
updateSortUI();
|
updateSortUI();
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
@@ -97,6 +102,7 @@ async function loadCards() {
|
|||||||
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
||||||
const meta = await api('api/cards/meta');
|
const meta = await api('api/cards/meta');
|
||||||
if (!meta) return;
|
if (!meta) return;
|
||||||
|
state.cardsLoaded = true;
|
||||||
state.cards = meta.map(c => ({ ...c, body: null }));
|
state.cards = meta.map(c => ({ ...c, body: null }));
|
||||||
setCachedCards(meta);
|
setCachedCards(meta);
|
||||||
updateSortUI();
|
updateSortUI();
|
||||||
@@ -158,7 +164,7 @@ importFile?.addEventListener('change', async e => {
|
|||||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
if (data.error) throw new Error(data.error);
|
if (data.error) throw new Error(data.error);
|
||||||
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
|
await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -587,9 +587,18 @@ export function renderBoard() {
|
|||||||
board.style.display = 'flex';
|
board.style.display = 'flex';
|
||||||
board.style.alignItems = 'center';
|
board.style.alignItems = 'center';
|
||||||
board.style.justifyContent = 'center';
|
board.style.justifyContent = 'center';
|
||||||
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
// Distinguish "genuinely empty" from "haven't heard back yet".
|
||||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
// Blurting "no cards found" during the initial load is scary
|
||||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
// on boards the user knows aren't empty.
|
||||||
|
if (!state.cardsLoaded) {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
|
||||||
|
} else {
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||||
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||||
|
}
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -186,6 +186,43 @@ const inpBucket = $('inp-bucket');
|
|||||||
const searchFilter = $('search-bucket-filter');
|
const searchFilter = $('search-bucket-filter');
|
||||||
const tasksFilter = $('tasks-bucket-filter');
|
const tasksFilter = $('tasks-bucket-filter');
|
||||||
|
|
||||||
|
// ── Overflow handling for the bucket bar ─────────────────────────
|
||||||
|
// Scrollbar is hidden (CSS). Mouse-wheel over the bar translates to
|
||||||
|
// horizontal scroll so long bucket rows stay usable without a bar.
|
||||||
|
// Edge fades kick in dynamically based on scroll position so the
|
||||||
|
// user sees there's more off-screen — both sides at once in the
|
||||||
|
// middle, one side at either extreme. Fade overlays live on the
|
||||||
|
// parent #bucket-row (see CSS) so they don't clip super-bucket
|
||||||
|
// popovers that render inside #bucket-bar itself.
|
||||||
|
const barRow = $('bucket-row');
|
||||||
|
function updateBucketBarFades() {
|
||||||
|
if (!bar || !barRow) return;
|
||||||
|
const canLeft = bar.scrollLeft > 2;
|
||||||
|
const canRight = bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 2;
|
||||||
|
barRow.classList.toggle('fade-left', canLeft && !canRight);
|
||||||
|
barRow.classList.toggle('fade-right', !canLeft && canRight);
|
||||||
|
barRow.classList.toggle('fade-both', canLeft && canRight);
|
||||||
|
// Right-side fade overlay is anchored to the parent's right edge;
|
||||||
|
// the actual bar ends before the toolbar toggles. Compute the
|
||||||
|
// distance so the gradient lines up with the bar's right edge.
|
||||||
|
const rowRect = barRow.getBoundingClientRect();
|
||||||
|
const barRect = bar.getBoundingClientRect();
|
||||||
|
const rightInset = Math.max(0, rowRect.right - barRect.right);
|
||||||
|
barRow.style.setProperty('--bucket-row-right-inset', rightInset + 'px');
|
||||||
|
}
|
||||||
|
if (bar) {
|
||||||
|
bar.addEventListener('wheel', (e) => {
|
||||||
|
if (e.deltaY === 0) return;
|
||||||
|
if (bar.scrollWidth <= bar.clientWidth) return;
|
||||||
|
e.preventDefault();
|
||||||
|
bar.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
bar.addEventListener('scroll', updateBucketBarFades, { passive: true });
|
||||||
|
window.addEventListener('resize', updateBucketBarFades);
|
||||||
|
new MutationObserver(updateBucketBarFades).observe(bar, { childList: true, subtree: false });
|
||||||
|
updateBucketBarFades();
|
||||||
|
}
|
||||||
|
|
||||||
/** Switch the active bucket without going through the click path —
|
/** Switch the active bucket without going through the click path —
|
||||||
* used by the number-key hotkeys (and any other code that wants to
|
* used by the number-key hotkeys (and any other code that wants to
|
||||||
* activate a bucket programmatically without triggering the super-
|
* activate a bucket programmatically without triggering the super-
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ function openDayPopup(ds, cellEl) {
|
|||||||
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
||||||
const col = cfg?.color || '#fb923c';
|
const col = cfg?.color || '#fb923c';
|
||||||
const calTag = calSlug ? ` #${calSlug}` : '';
|
const calTag = calSlug ? ` #${calSlug}` : '';
|
||||||
const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
|
const body = `**Zeit:** \n**Bis:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`;
|
||||||
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
|
window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null });
|
||||||
} else if (type === 'birthday') {
|
} else if (type === 'birthday') {
|
||||||
const body = `#geburtstag`;
|
const body = `#geburtstag`;
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ export const state = {
|
|||||||
// Data
|
// Data
|
||||||
cards: [],
|
cards: [],
|
||||||
buckets: [],
|
buckets: [],
|
||||||
|
// True once we've got a real response from api/cards (or the
|
||||||
|
// cached-cards hydration). Until then the board shows a spinner
|
||||||
|
// instead of the "no cards found" placeholder — an empty board on
|
||||||
|
// first load is an honest "still fetching" state, not a real empty.
|
||||||
|
cardsLoaded: false,
|
||||||
|
|
||||||
// Filters & view — activeBucket is persisted across reloads via
|
// Filters & view — activeBucket is persisted across reloads via
|
||||||
// localStorage. Numeric ids are stringified; the special filters
|
// localStorage. Numeric ids are stringified; the special filters
|
||||||
|
|||||||
@@ -29,6 +29,23 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Combined caret + title trigger. Navigable (cursor + accent hover)
|
||||||
|
only when there's somewhere to go back to — toggled by JS via the
|
||||||
|
`.navigable` class. On the list it reads like a static label. */
|
||||||
|
.chat-header-nav {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--t-bright);
|
||||||
|
cursor: default;
|
||||||
|
transition: color 0.12s;
|
||||||
|
}
|
||||||
|
.chat-header-nav.navigable { cursor: pointer; color: var(--t-muted); }
|
||||||
|
.chat-header-nav.navigable:hover { color: var(--t-accent); }
|
||||||
|
|
||||||
.chat-view {
|
.chat-view {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -3,8 +3,14 @@
|
|||||||
<div id="chat-flyout" class="hidden">
|
<div id="chat-flyout" class="hidden">
|
||||||
<div id="chat-flyout-header">
|
<div id="chat-flyout-header">
|
||||||
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
<i class="ph ph-chats-circle text-t-accent text-sm"></i>
|
||||||
<span id="chat-flyout-title" class="font-pixel text-[10px] text-t-bright">chat</span>
|
<!-- Caret + "chat" label are a single trigger with one tooltip.
|
||||||
<button id="chat-header-back" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="back to list"><i class="ph ph-caret-left text-sm"></i></button>
|
The caret shows only when there's somewhere to go back to
|
||||||
|
(thread / pick / room-info); on the list itself the click
|
||||||
|
is a no-op and the button styles itself as inert. -->
|
||||||
|
<button id="chat-header-nav" type="button" class="chat-header-nav" data-tip="back to conversations">
|
||||||
|
<i id="chat-header-back-caret" class="ph ph-caret-left text-sm hidden"></i>
|
||||||
|
<span id="chat-flyout-title" class="font-pixel text-[10px]">chat</span>
|
||||||
|
</button>
|
||||||
<span class="flex-1"></span>
|
<span class="flex-1"></span>
|
||||||
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
<button id="chat-thread-disposable" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="disposable: messages drop after delivery"><i class="ph ph-fire text-sm"></i></button>
|
||||||
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
|
<button id="chat-thread-info" class="hidden text-t-muted hover:text-t-accent transition-colors" data-tip="room info"><i class="ph ph-info text-sm"></i></button>
|
||||||
|
|||||||
@@ -179,11 +179,18 @@ function hideAllViews() {
|
|||||||
.forEach(id => $(id)?.classList.add('hidden'));
|
.forEach(id => $(id)?.classList.add('hidden'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toggle the caret + make the "chat" trigger look/feel clickable only
|
||||||
|
// when there's actually somewhere to go back to.
|
||||||
|
function setNavState(canBack) {
|
||||||
|
$('chat-header-back-caret')?.classList.toggle('hidden', !canBack);
|
||||||
|
$('chat-header-nav')?.classList.toggle('navigable', canBack);
|
||||||
|
}
|
||||||
|
|
||||||
function showListView() {
|
function showListView() {
|
||||||
hideAllViews();
|
hideAllViews();
|
||||||
$('chat-list-view').classList.remove('hidden');
|
$('chat-list-view').classList.remove('hidden');
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
$('chat-header-back')?.classList.add('hidden');
|
setNavState(false);
|
||||||
$('chat-thread-disposable')?.classList.add('hidden');
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
$('chat-thread-info')?.classList.add('hidden');
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
currentThread = null;
|
currentThread = null;
|
||||||
@@ -193,7 +200,7 @@ function showThreadView(thread) {
|
|||||||
hideAllViews();
|
hideAllViews();
|
||||||
$('chat-thread-view').classList.remove('hidden');
|
$('chat-thread-view').classList.remove('hidden');
|
||||||
$('chat-flyout-title').textContent = 'chat';
|
$('chat-flyout-title').textContent = 'chat';
|
||||||
$('chat-header-back')?.classList.remove('hidden');
|
setNavState(true);
|
||||||
currentThread = thread;
|
currentThread = thread;
|
||||||
// Room-only: info icon. 1:1-only: disposable icon.
|
// Room-only: info icon. 1:1-only: disposable icon.
|
||||||
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
|
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
|
||||||
@@ -205,7 +212,7 @@ function showThreadView(thread) {
|
|||||||
function showPickView() {
|
function showPickView() {
|
||||||
hideAllViews();
|
hideAllViews();
|
||||||
$('chat-pick-view').classList.remove('hidden');
|
$('chat-pick-view').classList.remove('hidden');
|
||||||
$('chat-header-back')?.classList.add('hidden');
|
setNavState(false);
|
||||||
$('chat-thread-disposable')?.classList.add('hidden');
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
$('chat-thread-info')?.classList.add('hidden');
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -213,7 +220,7 @@ function showPickView() {
|
|||||||
function showRoomInfoView() {
|
function showRoomInfoView() {
|
||||||
hideAllViews();
|
hideAllViews();
|
||||||
$('chat-room-info-view').classList.remove('hidden');
|
$('chat-room-info-view').classList.remove('hidden');
|
||||||
$('chat-header-back')?.classList.add('hidden');
|
setNavState(false);
|
||||||
$('chat-thread-disposable')?.classList.add('hidden');
|
$('chat-thread-disposable')?.classList.add('hidden');
|
||||||
$('chat-thread-info')?.classList.add('hidden');
|
$('chat-thread-info')?.classList.add('hidden');
|
||||||
}
|
}
|
||||||
@@ -749,7 +756,14 @@ function init() {
|
|||||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||||
$('chat-new-btn').addEventListener('click', openPickView);
|
$('chat-new-btn').addEventListener('click', openPickView);
|
||||||
$('chat-empty-start')?.addEventListener('click', openPickView);
|
$('chat-empty-start')?.addEventListener('click', openPickView);
|
||||||
$('chat-header-back').addEventListener('click', () => { showListView(); loadConversations(); });
|
// Caret + "chat" label are one button with one tooltip. Click is a
|
||||||
|
// no-op when the caret is hidden (already on the list) — the CSS
|
||||||
|
// .navigable class tracks whether the button looks clickable.
|
||||||
|
$('chat-header-nav')?.addEventListener('click', () => {
|
||||||
|
if (!$('chat-header-nav')?.classList.contains('navigable')) return;
|
||||||
|
showListView();
|
||||||
|
loadConversations();
|
||||||
|
});
|
||||||
$('chat-pick-back').addEventListener('click', showListView);
|
$('chat-pick-back').addEventListener('click', showListView);
|
||||||
$('chat-thread-form').addEventListener('submit', sendMessage);
|
$('chat-thread-form').addEventListener('submit', sendMessage);
|
||||||
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
|
$('chat-thread-info')?.addEventListener('click', openRoomInfo);
|
||||||
|
|||||||
@@ -392,6 +392,18 @@ async function createBucketDialog() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enter in the name field = Create (users expect the submit path
|
||||||
|
// without reaching for the mouse). Stop propagation so nothing
|
||||||
|
// upstream grabs it. No-op if name is empty — the submit button's
|
||||||
|
// own handler validates.
|
||||||
|
overlay.querySelector('#__cb_name').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
overlay.querySelector('.collab-btn-submit').click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
|
setTimeout(() => overlay.querySelector('#__cb_name')?.focus(), 50);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,20 +30,20 @@ describe('Chat: flyout', () => {
|
|||||||
expect(f.classList.contains('hidden')).toBe(true);
|
expect(f.classList.contains('hidden')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('header-back visibility matches active view', async () => {
|
it('header back-caret visibility matches active view', async () => {
|
||||||
await ensureFlyoutClosed();
|
await ensureFlyoutClosed();
|
||||||
click('#chat-footer-btn');
|
click('#chat-footer-btn');
|
||||||
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
||||||
// Wait for loadConversations + initial view pick to settle.
|
// Wait for loadConversations + initial view pick to settle.
|
||||||
await sleep(150);
|
await sleep(150);
|
||||||
const back = $('#chat-header-back');
|
const caret = $('#chat-header-back-caret');
|
||||||
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
|
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
|
||||||
const listShown = !$('#chat-list-view').classList.contains('hidden');
|
const listShown = !$('#chat-list-view').classList.contains('hidden');
|
||||||
// Exactly one of thread / list should be active here (pick view
|
// Exactly one of thread / list should be active here (pick view
|
||||||
// is only reached via the + button).
|
// is only reached via the + button).
|
||||||
expect(threadShown || listShown).toBe(true);
|
expect(threadShown || listShown).toBe(true);
|
||||||
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
|
if (threadShown) expect(caret.classList.contains('hidden')).toBe(false);
|
||||||
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
|
if (listShown) expect(caret.classList.contains('hidden')).toBe(true);
|
||||||
await ensureFlyoutClosed();
|
await ensureFlyoutClosed();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,9 +57,9 @@ describe('Chat: flyout', () => {
|
|||||||
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
|
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
|
||||||
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
|
expect($('#chat-list-view').classList.contains('hidden')).toBe(true);
|
||||||
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
|
expect($('#chat-thread-view').classList.contains('hidden')).toBe(true);
|
||||||
// Header back is only for thread view — not needed when pick has
|
// Back caret is only for thread view — not needed when pick has
|
||||||
// its own dedicated back button.
|
// its own dedicated back button.
|
||||||
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
|
expect($('#chat-header-back-caret').classList.contains('hidden')).toBe(true);
|
||||||
click('#chat-pick-back');
|
click('#chat-pick-back');
|
||||||
await sleep(50);
|
await sleep(50);
|
||||||
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
|
expect($('#chat-pick-view').classList.contains('hidden')).toBe(true);
|
||||||
@@ -120,18 +120,22 @@ describe('Chat: API', () => {
|
|||||||
|
|
||||||
it('/api/chat/thread-settings round-trips disposable flag', async () => {
|
it('/api/chat/thread-settings round-trips disposable flag', async () => {
|
||||||
const peer = '__test_disposable_flip__';
|
const peer = '__test_disposable_flip__';
|
||||||
const initial = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
const setFlag = (v) => fetch('api/chat/thread-settings', {
|
||||||
.then(r => r.json());
|
|
||||||
expect(initial.disposable).toBe(0);
|
|
||||||
const set = await fetch('api/chat/thread-settings', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
body: JSON.stringify({ peer, disposable: true }),
|
body: JSON.stringify({ peer, disposable: v }),
|
||||||
}).then(r => r.json());
|
}).then(r => r.json());
|
||||||
|
// Reset from any leftover state (previous runs leave the row
|
||||||
|
// behind — restore only rolls cards/buckets, not plugin tables).
|
||||||
|
const reset = await setFlag(false);
|
||||||
|
expect(reset.disposable).toBe(0);
|
||||||
|
const set = await setFlag(true);
|
||||||
expect(set.disposable).toBe(1);
|
expect(set.disposable).toBe(1);
|
||||||
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||||
.then(r => r.json());
|
.then(r => r.json());
|
||||||
expect(after.disposable).toBe(1);
|
expect(after.disposable).toBe(1);
|
||||||
|
// Leave a clean slate for the next run.
|
||||||
|
await setFlag(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -587,6 +587,16 @@ async function run() {
|
|||||||
await sleep(200);
|
await sleep(200);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
// 6a. Purge any editor-draft entries the tests wrote while poking
|
||||||
|
// the modal. Leaving them around means the next card open pops
|
||||||
|
// "unsaved draft from 0 min ago — restore?" to the user, which
|
||||||
|
// is noise since the tests weren't real edits of their cards.
|
||||||
|
try {
|
||||||
|
for (const k of Object.keys(localStorage)) {
|
||||||
|
if (k.startsWith('hyprcrd-editor-draft')) localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// 6b. Put the view back the way the user left it (super-view, sort,
|
// 6b. Put the view back the way the user left it (super-view, sort,
|
||||||
// filters, …) so launching the test runner doesn't silently wipe
|
// filters, …) so launching the test runner doesn't silently wipe
|
||||||
// their working context.
|
// their working context.
|
||||||
|
|||||||
Reference in new issue
Block a user