feat(fase-12): settings + manage toggles per section
Two new sections, both hooked into the precedence-aware features store:
* /settings — "Visible sections" block with one toggle per SectionKey
bound to setUserFeature(). When a collective-level override is
present the toggle is disabled and a one-line "Hidden by the
collective" hint replaces the visible state — making it obvious
why the per-user toggle is moot.
* /collective/manage — "Collective sections" block (admin-only,
inside the existing isAdmin guard) with one toggle per SectionKey
bound to setCollectiveFeature(). Flipping any of them propagates
to every member in realtime via the publication added in the
previous commit.
Both helpers fall back to the default-ON semantics of section_enabled
when the JSONB key is missing, so untouched rows render exactly as
before fase 12.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
import { currentUser } from '$lib/stores/auth';
|
import { currentUser } from '$lib/stores/auth';
|
||||||
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
import { currentCollective, collectiveMembers, userCollectives } from '$lib/stores/collective';
|
||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
|
import type { FeatureFlags, SectionKey } from '@colectivo/types';
|
||||||
|
import { SECTION_KEYS } from '@colectivo/types';
|
||||||
|
import { setCollectiveFeature } from '$lib/stores/features';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
|
|
||||||
type Member = {
|
type Member = {
|
||||||
@@ -236,6 +239,31 @@
|
|||||||
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
userCollectives.update((list) => list.map((it) => (it.id === c.id ? c : it)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fase 12.4.2 — collective section visibility (admin only) ────────────
|
||||||
|
function sectionLabel(section: SectionKey): string {
|
||||||
|
if (section === 'lists') return m.section_label_lists();
|
||||||
|
if (section === 'tasks') return m.section_label_tasks();
|
||||||
|
if (section === 'notes') return m.section_label_notes();
|
||||||
|
return m.section_label_search();
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectiveFlag(section: SectionKey): boolean {
|
||||||
|
const flags = (($currentCollective?.feature_flags ?? {}) as FeatureFlags);
|
||||||
|
// Missing key → ON (the collective has "no opinion" and the user layer
|
||||||
|
// can take over). Equivalent to section_enabled's collective coalesce.
|
||||||
|
return flags[section] ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCollectiveSection(section: SectionKey) {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
const next = !collectiveFlag(section);
|
||||||
|
try {
|
||||||
|
await setCollectiveFeature(section, next);
|
||||||
|
} catch {
|
||||||
|
// optimistic mutation already rolled back inside the helper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Fase 10.3: dissolve collective (CU-H08) ─────────────────────────────
|
// ── Fase 10.3: dissolve collective (CU-H08) ─────────────────────────────
|
||||||
let dissolveOpen = $state(false);
|
let dissolveOpen = $state(false);
|
||||||
let dissolveConfirmInput = $state('');
|
let dissolveConfirmInput = $state('');
|
||||||
@@ -511,6 +539,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Section visibility (Fase 12.4.2) — admin only. Collective-level
|
||||||
|
toggles are the higher precedence layer; turning one off hides
|
||||||
|
the section for every member. -->
|
||||||
|
<section data-testid="manage-section-visibility" class="mt-6">
|
||||||
|
<h2 class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.collective_section_visibility_title()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-3 text-xs text-text-secondary">
|
||||||
|
{m.collective_section_visibility_blurb()}
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each SECTION_KEYS as section (section)}
|
||||||
|
{@const enabled = collectiveFlag(section)}
|
||||||
|
<li class="flex items-center justify-between gap-3 rounded-md bg-surface-raised px-3 py-2">
|
||||||
|
<p class="min-w-0 flex-1 text-sm text-slate-900 dark:text-slate-50">{sectionLabel(section)}</p>
|
||||||
|
<label class="inline-flex shrink-0 cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="manage-section-toggle-{section}"
|
||||||
|
checked={enabled}
|
||||||
|
onchange={() => void toggleCollectiveSection(section)}
|
||||||
|
class="h-4 w-4 cursor-pointer accent-slate-900 dark:accent-slate-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Danger zone (Fase 10.3) — admin only -->
|
<!-- Danger zone (Fase 10.3) — admin only -->
|
||||||
<section class="mt-10 rounded-lg border border-destructive/40 p-4">
|
<section class="mt-10 rounded-lg border border-destructive/40 p-4">
|
||||||
<h2 class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
<h2 class="mb-2 text-[13px] font-semibold uppercase tracking-[0.05em] text-destructive">
|
||||||
|
|||||||
@@ -17,7 +17,12 @@
|
|||||||
recolorTag,
|
recolorTag,
|
||||||
deleteTag
|
deleteTag
|
||||||
} from '$lib/stores/tags';
|
} from '$lib/stores/tags';
|
||||||
import type { ItemTag, ItemTagColor } from '@colectivo/types';
|
import type { ItemTag, ItemTagColor, FeatureFlags, SectionKey } from '@colectivo/types';
|
||||||
|
import { SECTION_KEYS } from '@colectivo/types';
|
||||||
|
import {
|
||||||
|
currentUserFeatures,
|
||||||
|
setUserFeature
|
||||||
|
} from '$lib/stores/features';
|
||||||
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
import { languageTag, setLanguageTag } from '$lib/paraglide/runtime';
|
||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import type { ThemePreference } from '$lib/theme';
|
import type { ThemePreference } from '$lib/theme';
|
||||||
@@ -205,6 +210,39 @@
|
|||||||
.eq('id', $currentUser.id);
|
.eq('id', $currentUser.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fase 12.4.1 — section visibility toggles ────────────────────────────
|
||||||
|
// The "effective" map is derived from the user + active-collective flags
|
||||||
|
// (see $lib/stores/features). The disabled state of each toggle reflects
|
||||||
|
// whether the collective has explicitly hidden the section — when it has,
|
||||||
|
// the user toggle is moot and we surface that with a one-line hint.
|
||||||
|
function sectionLabel(section: SectionKey): string {
|
||||||
|
if (section === 'lists') return m.section_label_lists();
|
||||||
|
if (section === 'tasks') return m.section_label_tasks();
|
||||||
|
if (section === 'notes') return m.section_label_notes();
|
||||||
|
return m.section_label_search();
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFlag(section: SectionKey): boolean {
|
||||||
|
const flags = ($currentUserFeatures?.feature_flags ?? {}) as FeatureFlags;
|
||||||
|
const v = flags[section];
|
||||||
|
// Default ON when the key is missing or null — matches section_enabled().
|
||||||
|
return v ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectiveHidden(section: SectionKey): boolean {
|
||||||
|
const flags = ($currentCollective?.feature_flags ?? {}) as FeatureFlags;
|
||||||
|
return flags[section] === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUserSection(section: SectionKey) {
|
||||||
|
const next = !userFlag(section);
|
||||||
|
try {
|
||||||
|
await setUserFeature(section, next);
|
||||||
|
} catch {
|
||||||
|
// optimistic mutation already rolled back inside the helper
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function switchLanguage(lang: 'en' | 'es') {
|
async function switchLanguage(lang: 'en' | 'es') {
|
||||||
language = lang;
|
language = lang;
|
||||||
setLanguageTag(lang);
|
setLanguageTag(lang);
|
||||||
@@ -420,6 +458,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Section visibility (Fase 12.4.1). Per-user opt-out of top-level
|
||||||
|
sections. Collective-level overrides win — when present they
|
||||||
|
disable the toggle and explain why. -->
|
||||||
|
<section data-testid="settings-section-visibility">
|
||||||
|
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||||
|
{m.settings_section_visibility_title()}
|
||||||
|
</p>
|
||||||
|
<p class="mb-3 text-xs text-text-secondary">{m.settings_section_visibility_blurb()}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each SECTION_KEYS as section (section)}
|
||||||
|
{@const overridden = collectiveHidden(section)}
|
||||||
|
{@const checked = !overridden && userFlag(section)}
|
||||||
|
<li class="flex items-center justify-between gap-3 rounded-md bg-surface-raised px-3 py-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm text-slate-900 dark:text-slate-50">{sectionLabel(section)}</p>
|
||||||
|
{#if overridden}
|
||||||
|
<p class="text-xs text-text-secondary">{m.settings_section_visibility_overridden()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<label class="inline-flex shrink-0 cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="settings-section-toggle-{section}"
|
||||||
|
checked={checked}
|
||||||
|
disabled={overridden}
|
||||||
|
onchange={() => void toggleUserSection(section)}
|
||||||
|
class="h-4 w-4 cursor-pointer accent-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:accent-slate-50"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Fase 9.3.3 — sync conflicts debug panel. Gated on
|
<!-- Fase 9.3.3 — sync conflicts debug panel. Gated on
|
||||||
import.meta.env.DEV inside the component itself. -->
|
import.meta.env.DEV inside the component itself. -->
|
||||||
<SyncConflictsPanel />
|
<SyncConflictsPanel />
|
||||||
|
|||||||
Reference in New Issue
Block a user