Compare commits
10
Commits
dce8fa4211
...
64be9257f2
| 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.
|
||||
|
||||
### 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
|
||||
|
||||
### 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`.
|
||||
|
||||
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
|
||||
|
||||
1. Open the relay URL **from a desktop browser** — the setup wizard
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '1.9.5');
|
||||
define('APP_VERSION', '1.9.12');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/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")"
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose --profile relay up -d --build
|
||||
|
||||
echo ""
|
||||
echo " hyprcrd: http://localhost:8088"
|
||||
@@ -11,3 +13,5 @@ echo " relay: http://localhost:8081"
|
||||
echo ""
|
||||
echo " relay from hyprcrd container: http://relay:80"
|
||||
echo ""
|
||||
echo " (for app-only, drop the relay profile: docker compose up -d)"
|
||||
echo ""
|
||||
@@ -9,8 +9,12 @@ services:
|
||||
- ./public:/var/www/html/public
|
||||
- ./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:
|
||||
build: ./relay
|
||||
profiles: ["relay"]
|
||||
ports:
|
||||
- "8081:80"
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hyprcrd",
|
||||
"version": "1.9.5",
|
||||
"version": "1.9.12",
|
||||
"description": "hyprcrd — self-hosted card manager",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -780,6 +780,50 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
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
|
||||
arrive while the user is viewing a different bucket. Cleared the
|
||||
moment the user navigates back to all (renderBoard drains the
|
||||
|
||||
+8
-2
@@ -45,6 +45,7 @@ window.openFindBar = openFindBar;
|
||||
window._reloadAll = async () => {
|
||||
await loadBuckets();
|
||||
state.cards = await api('api/cards') || [];
|
||||
state.cardsLoaded = true;
|
||||
renderBoard();
|
||||
refreshCalendar();
|
||||
// Let plugins / modules react to a full card reload (detail view checks
|
||||
@@ -86,10 +87,14 @@ function setCachedCards(cards) {
|
||||
}
|
||||
|
||||
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();
|
||||
if (cached.length) {
|
||||
state.cards = cached.map(c => ({ ...c, body: null }));
|
||||
state.cardsLoaded = true;
|
||||
updateSortUI();
|
||||
renderBoard();
|
||||
}
|
||||
@@ -97,6 +102,7 @@ async function loadCards() {
|
||||
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
||||
const meta = await api('api/cards/meta');
|
||||
if (!meta) return;
|
||||
state.cardsLoaded = true;
|
||||
state.cards = meta.map(c => ({ ...c, body: null }));
|
||||
setCachedCards(meta);
|
||||
updateSortUI();
|
||||
@@ -158,7 +164,7 @@ importFile?.addEventListener('change', async e => {
|
||||
if (!res.ok) throw new Error(text || `HTTP ${res.status}`);
|
||||
const data = JSON.parse(text);
|
||||
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) {
|
||||
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.alignItems = 'center';
|
||||
board.style.justifyContent = 'center';
|
||||
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>`;
|
||||
// Distinguish "genuinely empty" from "haven't heard back yet".
|
||||
// Blurting "no cards found" during the initial load is scary
|
||||
// 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');
|
||||
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
|
||||
return;
|
||||
|
||||
@@ -186,6 +186,43 @@ const inpBucket = $('inp-bucket');
|
||||
const searchFilter = $('search-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 —
|
||||
* used by the number-key hotkeys (and any other code that wants to
|
||||
* activate a bucket programmatically without triggering the super-
|
||||
|
||||
@@ -493,7 +493,7 @@ function openDayPopup(ds, cellEl) {
|
||||
const cfg = calSlug ? getCalConfig(calSlug) : null;
|
||||
const col = cfg?.color || '#fb923c';
|
||||
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 });
|
||||
} else if (type === 'birthday') {
|
||||
const body = `#geburtstag`;
|
||||
|
||||
@@ -6,6 +6,11 @@ export const state = {
|
||||
// Data
|
||||
cards: [],
|
||||
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
|
||||
// localStorage. Numeric ids are stringified; the special filters
|
||||
|
||||
@@ -29,6 +29,23 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
<div id="chat-flyout" class="hidden">
|
||||
<div id="chat-flyout-header">
|
||||
<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>
|
||||
<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>
|
||||
<!-- Caret + "chat" label are a single trigger with one tooltip.
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -179,11 +179,18 @@ function hideAllViews() {
|
||||
.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() {
|
||||
hideAllViews();
|
||||
$('chat-list-view').classList.remove('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-header-back')?.classList.add('hidden');
|
||||
setNavState(false);
|
||||
$('chat-thread-disposable')?.classList.add('hidden');
|
||||
$('chat-thread-info')?.classList.add('hidden');
|
||||
currentThread = null;
|
||||
@@ -193,7 +200,7 @@ function showThreadView(thread) {
|
||||
hideAllViews();
|
||||
$('chat-thread-view').classList.remove('hidden');
|
||||
$('chat-flyout-title').textContent = 'chat';
|
||||
$('chat-header-back')?.classList.remove('hidden');
|
||||
setNavState(true);
|
||||
currentThread = thread;
|
||||
// Room-only: info icon. 1:1-only: disposable icon.
|
||||
$('chat-thread-info')?.classList.toggle('hidden', thread.kind !== 'room');
|
||||
@@ -205,7 +212,7 @@ function showThreadView(thread) {
|
||||
function showPickView() {
|
||||
hideAllViews();
|
||||
$('chat-pick-view').classList.remove('hidden');
|
||||
$('chat-header-back')?.classList.add('hidden');
|
||||
setNavState(false);
|
||||
$('chat-thread-disposable')?.classList.add('hidden');
|
||||
$('chat-thread-info')?.classList.add('hidden');
|
||||
}
|
||||
@@ -213,7 +220,7 @@ function showPickView() {
|
||||
function showRoomInfoView() {
|
||||
hideAllViews();
|
||||
$('chat-room-info-view').classList.remove('hidden');
|
||||
$('chat-header-back')?.classList.add('hidden');
|
||||
setNavState(false);
|
||||
$('chat-thread-disposable')?.classList.add('hidden');
|
||||
$('chat-thread-info')?.classList.add('hidden');
|
||||
}
|
||||
@@ -749,7 +756,14 @@ function init() {
|
||||
$('chat-refresh-btn').addEventListener('click', () => pollOnce().then(loadConversations));
|
||||
$('chat-new-btn').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-thread-form').addEventListener('submit', sendMessage);
|
||||
$('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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,20 +30,20 @@ describe('Chat: flyout', () => {
|
||||
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();
|
||||
click('#chat-footer-btn');
|
||||
await waitFor(() => !$('#chat-flyout').classList.contains('hidden'));
|
||||
// Wait for loadConversations + initial view pick to settle.
|
||||
await sleep(150);
|
||||
const back = $('#chat-header-back');
|
||||
const caret = $('#chat-header-back-caret');
|
||||
const threadShown = !$('#chat-thread-view').classList.contains('hidden');
|
||||
const listShown = !$('#chat-list-view').classList.contains('hidden');
|
||||
// Exactly one of thread / list should be active here (pick view
|
||||
// is only reached via the + button).
|
||||
expect(threadShown || listShown).toBe(true);
|
||||
if (threadShown) expect(back.classList.contains('hidden')).toBe(false);
|
||||
if (listShown) expect(back.classList.contains('hidden')).toBe(true);
|
||||
if (threadShown) expect(caret.classList.contains('hidden')).toBe(false);
|
||||
if (listShown) expect(caret.classList.contains('hidden')).toBe(true);
|
||||
await ensureFlyoutClosed();
|
||||
});
|
||||
|
||||
@@ -57,9 +57,9 @@ describe('Chat: flyout', () => {
|
||||
expect($('#chat-pick-view').classList.contains('hidden')).toBe(false);
|
||||
expect($('#chat-list-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.
|
||||
expect($('#chat-header-back').classList.contains('hidden')).toBe(true);
|
||||
expect($('#chat-header-back-caret').classList.contains('hidden')).toBe(true);
|
||||
click('#chat-pick-back');
|
||||
await sleep(50);
|
||||
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 () => {
|
||||
const peer = '__test_disposable_flip__';
|
||||
const initial = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||
.then(r => r.json());
|
||||
expect(initial.disposable).toBe(0);
|
||||
const set = await fetch('api/chat/thread-settings', {
|
||||
const setFlag = (v) => fetch('api/chat/thread-settings', {
|
||||
method: 'POST',
|
||||
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());
|
||||
// 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);
|
||||
const after = await fetch('api/chat/thread-settings?peer=' + encodeURIComponent(peer))
|
||||
.then(r => r.json());
|
||||
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);
|
||||
} 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,
|
||||
// filters, …) so launching the test runner doesn't silently wipe
|
||||
// their working context.
|
||||
|
||||
Reference in new issue
Block a user