Compare commits

..

2 Commits

Author SHA1 Message Date
JCEEE 48279be756 1.9.4 2026-09-08 09:43:37 +01:00
JCEEE a5e60b5486 add new flag to disable some future features and make some updates to chat ux 2026-09-08 09:42:58 +01:00
6 changed files with 129 additions and 26 deletions
+37 -7
View File
@@ -39,6 +39,19 @@
} }
}); });
// Close the panel with Escape while it's open.
$effect(() => {
if (!chatStore.open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
async function send() { async function send() {
const text = draft.trim(); const text = draft.trim();
if (!text || sending) return; if (!text || sending) return;
@@ -82,18 +95,27 @@
draft = (draft ? draft + ' ' : '') + '@' + name + ' '; draft = (draft ? draft + ' ' : '') + '@' + name + ' ';
} }
// Render content, wrapping @mentions in a styled token. // Render content, wrapping @mentions in a styled token and https:// URLs in
// clickable links (opened in a new window).
function renderContent(text: string) { function renderContent(text: string) {
const segs: { text: string; mention: boolean }[] = []; const segs: { text: string; mention: boolean; url: string | null }[] = [];
const re = /(@[A-Za-z0-9_.-]+)/g; const re = /(@[A-Za-z0-9_.-]+|https?:\/\/[^\s]+)/g;
let last = 0; let last = 0;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) { while ((m = re.exec(text)) !== null) {
if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false }); if (m.index > last) segs.push({ text: text.slice(last, m.index), mention: false, url: null });
segs.push({ text: m[1], mention: true }); const tok = m[1];
if (tok.startsWith('@')) {
segs.push({ text: tok, mention: true, url: null });
} else {
const url = tok.replace(/[.,;:!?]+$/, '');
if (url) segs.push({ text: url, mention: false, url });
const rest = tok.slice(url.length);
if (rest) segs.push({ text: rest, mention: false, url: null });
}
last = m.index + m[0].length; last = m.index + m[0].length;
} }
if (last < text.length) segs.push({ text: text.slice(last), mention: false }); if (last < text.length) segs.push({ text: text.slice(last), mention: false, url: null });
return segs; return segs;
} }
@@ -132,6 +154,8 @@
<button class="mention" onclick={() => insertMention(seg.text.slice(1))} <button class="mention" onclick={() => insertMention(seg.text.slice(1))}
>{seg.text}</button >{seg.text}</button
> >
{:else if seg.url}
<a class="msg-link" href={seg.url} target="_blank" rel="noopener noreferrer">{seg.url}</a>
{:else} {:else}
{seg.text} {seg.text}
{/if} {/if}
@@ -206,7 +230,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.85rem 1.25rem; height: 52px;
padding: 0 1.25rem;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
background: #4338ca; background: #4338ca;
color: #fff; color: #fff;
@@ -309,6 +334,11 @@
font-size: 0.85rem; font-size: 0.85rem;
cursor: pointer; cursor: pointer;
} }
.msg-link {
color: #6366f1;
text-decoration: underline;
word-break: break-all;
}
.chat-typing { .chat-typing {
min-height: 1.5rem; min-height: 1.5rem;
padding: 0 1.25rem; padding: 0 1.25rem;
+1 -1
View File
@@ -356,7 +356,7 @@ async function seedPlatform(): Promise<void> {
const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, { const created = await fetch(`${PB_ENDPOINT}/api/collections/platform/records`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ label: 'global', flags: { debug: false } }) body: JSON.stringify({ label: 'global', flags: { debug: false, rewardsTargets: false } })
}); });
const c = await created.json(); const c = await created.json();
if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`); if (!created.ok) throw new Error(`Seed platform failed: ${JSON.stringify(c)}`);
+1 -2
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import Footer from '$lib/components/Footer.svelte'; import Footer from '$lib/components/Footer.svelte';
import FamDone from '$lib/components/FamDone.svelte'; import FamDone from '$lib/components/FamDone.svelte';
import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons'; import { homeIcon, checkCircleIcon, chevronRightIcon } from '$lib/components/icons';
@@ -8,7 +7,7 @@
let shortcut = $state<(Shortcut & { target: string }) | null>(null); let shortcut = $state<(Shortcut & { target: string }) | null>(null);
onMount(() => { $effect(() => {
const s = readShortcut(); const s = readShortcut();
if (s) shortcut = { ...s, target: shortcutTarget(s) }; if (s) shortcut = { ...s, target: shortcutTarget(s) };
}); });
@@ -56,7 +56,12 @@
// Kanban columns can collapse (accordion) so templates can be dragged onto // Kanban columns can collapse (accordion) so templates can be dragged onto
// the desired area on tablet/mobile without cramming three columns. // the desired area on tablet/mobile without cramming three columns.
let collapsed = $state({ member: false, comp: false, collab: false }); let collapsed = $state({ member: false, comp: false, collab: false });
// `rewardsTargets` platform flag: OFF = simplified flow — rewards are always
// Individual, and the Competition/Collaboration columns become locked
// (faded, "Coming soon") row accordions instead of a 3-column grid.
let targetsEnabled = $derived(!!page.data.platformFlags?.rewardsTargets);
function toggleCol(key: 'member' | 'comp' | 'collab') { function toggleCol(key: 'member' | 'comp' | 'collab') {
if (!targetsEnabled && key !== 'member') return;
collapsed[key] = !collapsed[key]; collapsed[key] = !collapsed[key];
} }
@@ -146,7 +151,7 @@
editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value }; editVals.targetChoreIds = { ...editVals.targetChoreIds, [memberId]: value };
} }
let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global)); let templateConfigs = $derived((templates as BonusTemplate[]).filter((t) => t.global && !t.isPocketMoney));
let allBonusConfigs = $derived( let allBonusConfigs = $derived(
configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[] configs.filter((c: BonusConfig) => c.status !== 'completed') as BonusConfig[]
); );
@@ -212,7 +217,7 @@
editVals = { editVals = {
name: tpl.name, name: tpl.name,
description: tpl.description || '', description: tpl.description || '',
target: targetType, target: !targetsEnabled ? 'individual' : targetType,
memberId: '', memberId: '',
type: tpl.type, type: tpl.type,
thresholdType: tpl.thresholdType || 'points', thresholdType: tpl.thresholdType || 'points',
@@ -272,7 +277,7 @@
editVals = { editVals = {
name: cfg.name, name: cfg.name,
description: cfg.description || '', description: cfg.description || '',
target: cfg.target, target: !targetsEnabled ? 'individual' : cfg.target,
memberId: cfg.memberId || '', memberId: cfg.memberId || '',
type: cfg.type, type: cfg.type,
thresholdType: cfg.thresholdType || 'points', thresholdType: cfg.thresholdType || 'points',
@@ -359,7 +364,7 @@
</div> </div>
</Card> </Card>
<Card cols={3} scrollX> <Card cols={3} scrollX>
<div class="kanban"> <div class="kanban" class:rows={!targetsEnabled}>
<!-- Member Column --> <!-- Member Column -->
<div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}> <div class="column member-col" class:collapsed={collapsed.member} ondragover={handleDragOver} ondrop={handleDropToMember}>
<button class="col-header" type="button" onclick={() => toggleCol('member')}> <button class="col-header" type="button" onclick={() => toggleCol('member')}>
@@ -440,10 +445,19 @@
</div> </div>
<!-- Competition Column --> <!-- Competition Column -->
<div class="column comp-col" class:collapsed={collapsed.comp} ondragover={handleDragOver} ondrop={handleDropToCompetition}> <div
class="column comp-col"
class:locked={!targetsEnabled}
class:collapsed={targetsEnabled ? collapsed.comp : true}
ondragover={targetsEnabled ? handleDragOver : undefined}
ondrop={targetsEnabled ? handleDropToCompetition : undefined}
>
<button class="col-header" type="button" onclick={() => toggleCol('comp')}> <button class="col-header" type="button" onclick={() => toggleCol('comp')}>
<span class="col-title">Competition</span> <span class="col-title">Competition</span>
<span class="chev" class:open={!collapsed.comp}>{@html chevronRight}</span> {#if !targetsEnabled}
<span class="soon-badge">Coming soon</span>
{/if}
<span class="chev" class:open={targetsEnabled ? !collapsed.comp : false}>{@html chevronRight}</span>
</button> </button>
<div class="col-body"> <div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg} {#each allBonusConfigs.filter((c) => c.target === 'competitive' && !doneConfigs.includes(c)) as cfg}
@@ -506,10 +520,19 @@
</div> </div>
<!-- Collaboration Column --> <!-- Collaboration Column -->
<div class="column collab-col" class:collapsed={collapsed.collab} ondragover={handleDragOver} ondrop={handleDropToCollaboration}> <div
class="column collab-col"
class:locked={!targetsEnabled}
class:collapsed={targetsEnabled ? collapsed.collab : true}
ondragover={targetsEnabled ? handleDragOver : undefined}
ondrop={targetsEnabled ? handleDropToCollaboration : undefined}
>
<button class="col-header" type="button" onclick={() => toggleCol('collab')}> <button class="col-header" type="button" onclick={() => toggleCol('collab')}>
<span class="col-title">Collaboration</span> <span class="col-title">Collaboration</span>
<span class="chev" class:open={!collapsed.collab}>{@html chevronRight}</span> {#if !targetsEnabled}
<span class="soon-badge">Coming soon</span>
{/if}
<span class="chev" class:open={targetsEnabled ? !collapsed.collab : false}>{@html chevronRight}</span>
</button> </button>
<div class="col-body"> <div class="col-body">
{#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg} {#each allBonusConfigs.filter((c) => c.target === 'collaborative') as cfg}
@@ -637,6 +660,7 @@
<input name="description" bind:value={editVals.description} /> <input name="description" bind:value={editVals.description} />
</label> </label>
{/if} {/if}
{#if targetsEnabled}
<label> <label>
<span class="field-label"><UsersRound size={14} /> Target (who)</span> <span class="field-label"><UsersRound size={14} /> Target (who)</span>
<select name="target" bind:value={editVals.target}> <select name="target" bind:value={editVals.target}>
@@ -645,6 +669,10 @@
<option value="collaborative">Collaborative</option> <option value="collaborative">Collaborative</option>
</select> </select>
</label> </label>
{:else}
<input type="hidden" name="target" value="individual" />
<p class="hint">Rewards are always created for individual members.</p>
{/if}
{#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'} {#if (editingConfig || creatingFromTemplate) && editVals.target === 'individual'}
<label> <label>
<span class="field-label"><UsersRound size={14} /> Member</span> <span class="field-label"><UsersRound size={14} /> Member</span>
@@ -1100,6 +1128,33 @@
.column.collapsed .col-body { .column.collapsed .col-body {
display: none; display: none;
} }
.kanban.rows {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.kanban.rows .column {
width: 100%;
}
.column.locked {
opacity: 0.55;
pointer-events: none;
}
.column.locked .col-header {
cursor: not-allowed;
}
.soon-badge {
margin-left: auto;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
background: #e5e7eb;
color: #6b7280;
white-space: nowrap;
}
.card-head { .card-head {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+19
View File
@@ -188,6 +188,20 @@
</button> </button>
</form> </form>
</div> </div>
<div class="flag-row">
<span class="flag-name">rewardsTargets</span>
<span class="flag-desc">Multi-target reward rules (Competition / Collaboration)</span>
<form method="POST" action="?/togglePlatformFlag" use:enhance>
<input type="hidden" name="flag" value="rewardsTargets" />
<button
type="submit"
class="toggle-btn"
class:active={page.data.platformFlags?.rewardsTargets}
>
{page.data.platformFlags?.rewardsTargets ? 'ON' : 'OFF'}
</button>
</form>
</div>
</Card> </Card>
</CardGrid> </CardGrid>
@@ -923,6 +937,11 @@
font-size: 0.85rem; font-size: 0.85rem;
color: #374151; color: #374151;
} }
.flag-desc {
font-size: 0.7rem;
color: #9ca3af;
text-align: center;
}
.toggle-btn { .toggle-btn {
background: #e5e7eb; background: #e5e7eb;
+1 -1
View File
@@ -13,5 +13,5 @@
"esbuild" "esbuild"
] ]
}, },
"version": "1.9.3" "version": "1.9.4"
} }