unstable sharing refactor

This commit is contained in:
2026-03-19 16:15:47 -05:00
parent 4259649fec
commit 37004e4b4a
18 changed files with 1304 additions and 1248 deletions
+141 -18
View File
@@ -1,6 +1,6 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors } from "@/store";
import { useTheme } from "@/components/ThemeProvider";
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker";
@@ -25,13 +25,12 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
const [isBucketsOpen, setIsBucketsOpen] = createSignal(false);
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
const [shareUserId, setShareUserId] = createSignal("");
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
const [shareType, setShareType] = createSignal<'all' | 'tag'>('tag');
const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER');
const [shareTag, setShareTag] = createSignal<string>("");
const [allUsers, setAllUsers] = createSignal<Array<{ id: string; name: string }>>([]);
const [usersLoading, setUsersLoading] = createSignal(false);
// Fetch users when sharing section opens
createEffect(() => {
if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) {
setUsersLoading(true);
@@ -53,6 +52,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
return allUsers().filter(u => u.id !== currentUserId);
};
const userContexts = () =>
store.contexts
.filter(context => !context.deletedAt && context.kind === "user")
.sort((a, b) => a.displayName.localeCompare(b.displayName));
return (
<div class="w-full max-w-2xl mx-auto py-4 sm:py-10 px-4 sm:px-0 space-y-8 sm:space-y-12 animate-in fade-in slide-in-from-bottom-2 duration-500 min-w-0 overflow-hidden text-balance">
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -113,9 +117,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Share2 size={16} />
Task Sharing
Tag Sharing
</h3>
<p class="text-sm text-muted-foreground">Share all tasks or tasks with specific tags with others.</p>
<p class="text-sm text-muted-foreground">Share through tags only: `@people` collaborate, `@buckets` route work, and `#notes` stay link-only.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isSharingOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -124,6 +128,112 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<Show when={isSharingOpen()}>
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">User Tag Policies</h4>
<p class="text-[0.6875rem] text-muted-foreground">
Control how each <span class="font-semibold text-foreground">@user</span> tag behaves. Collaborative tags keep work shared. Handoff tags route work into that user&apos;s queue.
</p>
<div class="space-y-2">
<For each={userContexts()} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
No user contexts found yet.
</div>
}>
{(context) => (
<div class="flex items-center justify-between gap-3 p-3 rounded-xl border border-border/40 bg-background/70">
<div class="min-w-0">
<p class="text-sm font-bold truncate">@{context.displayName}</p>
<p class="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
{context.targetUserId === pb.authStore.model?.id ? "Your personal context" : `User: ${getUserName(context.targetUserId || "")}`}
</p>
</div>
<div class="flex gap-2 shrink-0">
<Button
variant={context.policy === 'collaborative' ? 'default' : 'outline'}
size="sm"
class="h-7 text-[0.625rem] gap-1 px-2"
onClick={() => updateUserContextPolicy(context.id, 'collaborative')}
>
<Users size={10} />
Collaborate
</Button>
<Button
variant={context.policy === 'handoff' ? 'default' : 'outline'}
size="sm"
class="h-7 text-[0.625rem] gap-1 px-2"
onClick={() => updateUserContextPolicy(context.id, 'handoff')}
>
<ArrowLeftRight size={10} />
Handoff
</Button>
</div>
</div>
)}
</For>
</div>
</div>
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Personal Context Supervision</h4>
<p class="text-[0.6875rem] text-muted-foreground">
Allow specific supervisors to switch into your whole personal context, the same way they switch into pinned bucket contexts.
</p>
<For each={availableUsers()} fallback={
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
No other users available.
</div>
}>
{(user) => {
const hasAccess = () => store.personalContextSupervisorIds.includes(user.id);
return (
<div class="flex items-center justify-between gap-3 p-3 rounded-xl border border-border/40 bg-background/70">
<div class="min-w-0">
<p class="text-sm font-bold truncate">{user.name}</p>
<p class="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
{hasAccess() ? "Can switch into your personal context" : "No supervision access"}
</p>
</div>
<Button
variant={hasAccess() ? "default" : "outline"}
size="sm"
class="h-8 text-[0.625rem] px-3"
onClick={() => {
const nextIds = hasAccess()
? store.personalContextSupervisorIds.filter(id => id !== user.id)
: [...store.personalContextSupervisorIds, user.id];
savePersonalContextSupervisors(nextIds);
}}
>
{hasAccess() ? "Allowed" : "Grant Access"}
</Button>
</div>
);
}}
</For>
</div>
<Show when={store.supervisedContexts.length > 0}>
<div class="space-y-3 pt-2">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pb-1 flex items-center gap-2">
<div class="w-1 h-1 rounded-full bg-orange-500" />
Personal Contexts Shared With You
</h4>
<For each={store.supervisedContexts}>
{(context) => (
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3">
<div class="min-w-0">
<p class="text-xs font-bold truncate">{context.ownerName}</p>
<p class="text-[0.5625rem] text-muted-foreground uppercase font-black tracking-widest opacity-60">
Available in the context switcher
</p>
</div>
</div>
)}
</For>
</div>
</Show>
<Show when={false}>
{/* Add new share rule */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<div class="flex gap-2">
@@ -131,10 +241,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
variant={shareType() === 'all' ? 'default' : 'outline'}
size="sm"
class="h-8 text-xs gap-1"
onClick={() => setShareType('all')}
disabled
title="All-task sharing is no longer supported"
>
<Users size={12} />
All Tasks
Legacy Off
</Button>
<Button
variant={shareType() === 'tag' ? 'default' : 'outline'}
@@ -188,7 +299,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
}
}
}}
options={store.tagDefinitions.map(t => t.name)}
options={store.tagDefinitions.filter(t => t.name.startsWith("@")).map(t => t.name)}
placeholder="Select a tag"
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
>
@@ -221,10 +332,10 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<Button
size="sm"
class="h-9 gap-2"
disabled={!shareUserId()}
disabled={!shareUserId() || !shareTag()}
onClick={async () => {
if (shareUserId()) {
await addShareRule(shareType(), shareUserId(), shareType() === 'tag' ? shareTag() : undefined, shareMode());
if (shareUserId() && shareTag()) {
await addShareRule('tag', shareUserId(), shareTag(), shareMode());
setShareUserId("");
}
}}
@@ -235,8 +346,8 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
</div>
<p class="text-[0.625rem] text-muted-foreground italic px-1 pt-1">
{shareMode() === 'ADD_USER'
? "User will be added to the task's shared list."
: "Task owner will change to the recipient immediately when tagged."}
? "Collaborative contexts add access through the selected @tag."
: "Handoff contexts transfer ownership when that @tag is applied."}
</p>
</div>
@@ -378,6 +489,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
</For>
</div>
</details>
</Show>
</div>
</Show>
</section>
@@ -392,9 +504,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Box size={16} />
Shared Global Buckets
Shared Buckets
</h3>
<p class="text-sm text-muted-foreground">Manage public task buckets and your subscriptions.</p>
<p class="text-sm text-muted-foreground">Each bucket is a real `@context` with a policy, metadata, soft delete, and personal pinning.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isBucketsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
@@ -405,7 +517,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
{/* Create Bucket */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket</h4>
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket Context</h4>
<form
class="flex flex-col sm:flex-row gap-2"
onSubmit={(e) => {
@@ -455,7 +567,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
<div class="space-y-2">
<For each={store.buckets} fallback={
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
No buckets found.
No bucket contexts found.
</div>
}>
{(bucket) => {
@@ -497,10 +609,21 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
/>
</Show>
<p class="text-[0.625rem] text-muted-foreground truncate opacity-80">{bucket.description || "No description"}</p>
<p class="text-[0.5625rem] uppercase tracking-widest text-muted-foreground/60 mt-1">@{bucket.name}</p>
</div>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
class="h-8 text-[0.625rem] uppercase tracking-wider"
onClick={() => updateBucket(bucket.id, {
policy: bucket.policy === "handoff" ? "collaborative" : "handoff"
})}
>
{bucket.policy === "handoff" ? "Handoff" : "Collaborative"}
</Button>
<Button
variant={isSubscribed() ? "secondary" : "outline"}
size="sm"
@@ -528,7 +651,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover/bucket:opacity-100 transition-opacity"
onClick={() => {
if (confirm(`Delete the bucket "${bucket.name}" ? This cannot be undone and will affect all users.`)) {
if (confirm(`Archive the bucket "${bucket.name}"? Tasks stay intact and the bucket can be restored later.`)) {
deleteBucket(bucket.id);
}
}}