unstable sharing refactor
This commit is contained in:
@@ -7,7 +7,12 @@ interface EmbedLayoutProps {
|
||||
export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
|
||||
return (
|
||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-background">
|
||||
<main
|
||||
class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-background"
|
||||
style={{
|
||||
"padding-bottom": "calc(8px + env(safe-area-inset-bottom, 0px))"
|
||||
}}
|
||||
>
|
||||
<div class="w-full h-full mx-auto p-4">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -196,10 +196,15 @@ export const Layout: Component<LayoutProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main class={cn(
|
||||
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
||||
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
||||
)}>
|
||||
<main
|
||||
class={cn(
|
||||
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
||||
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
||||
)}
|
||||
style={{
|
||||
"padding-bottom": "calc(8px + env(safe-area-inset-bottom, 0px))"
|
||||
}}
|
||||
>
|
||||
<div class={cn(
|
||||
"w-full h-full mx-auto",
|
||||
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from "./ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { createResource, createSignal, createEffect } from "solid-js";
|
||||
import { createSignal, createEffect } from "solid-js";
|
||||
|
||||
interface NavItem {
|
||||
icon: any;
|
||||
@@ -121,7 +121,6 @@ export const ContextSwitcher: Component<{
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
isMobile?: boolean;
|
||||
}> = (props) => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
const [open, setOpen] = createSignal(false);
|
||||
|
||||
// Sync with parent if needed
|
||||
@@ -136,37 +135,37 @@ export const ContextSwitcher: Component<{
|
||||
}
|
||||
});
|
||||
|
||||
const [oversightUsers] = createResource(
|
||||
() => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))],
|
||||
async (ownerIds) => {
|
||||
if (ownerIds.length === 0) return [];
|
||||
try {
|
||||
// Fetch user names for these IDs in a single query
|
||||
const filter = ownerIds.map(id => `id="${id}"`).join(' || ');
|
||||
const users = await pb.collection('users').getFullList({
|
||||
filter,
|
||||
fields: 'id,name,email',
|
||||
requestKey: null // Disable auto-cancellation
|
||||
});
|
||||
return users.map(u => ({ id: u.id, name: u.name || u.email }));
|
||||
} catch (e: any) {
|
||||
if (e.isAbort) return []; // Ignore explicit aborts
|
||||
console.error("Failed to load oversight users", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const ctx = currentTaskContext; // Accessor
|
||||
const personalContext = () =>
|
||||
store.contexts.find(context =>
|
||||
!context.deletedAt &&
|
||||
context.kind === "user" &&
|
||||
context.targetUserId === pb.authStore.model?.id
|
||||
) || null;
|
||||
const isPersonalContext = () => {
|
||||
const current = ctx();
|
||||
const personal = personalContext();
|
||||
return current === 'mine' || (
|
||||
typeof current === "object" &&
|
||||
"bucketId" in current &&
|
||||
!!personal &&
|
||||
current.bucketId === personal.id
|
||||
);
|
||||
};
|
||||
|
||||
const label = () => {
|
||||
const c = ctx();
|
||||
return c === 'mine' ? "My Bucket" : c.name;
|
||||
return isPersonalContext() ? "My Bucket" : (c === 'mine' ? "My Bucket" : c.name);
|
||||
};
|
||||
|
||||
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
||||
if (type === 'mine') {
|
||||
setCurrentTaskContext('mine');
|
||||
const personal = personalContext();
|
||||
if (personal) {
|
||||
setCurrentTaskContext({ bucketId: personal.id, name: "My Bucket", isPersonal: true });
|
||||
} else {
|
||||
setCurrentTaskContext('mine');
|
||||
}
|
||||
} else if (type === 'bucket') {
|
||||
setCurrentTaskContext({ bucketId: id, name });
|
||||
} else {
|
||||
@@ -182,7 +181,7 @@ export const ContextSwitcher: Component<{
|
||||
}
|
||||
};
|
||||
|
||||
const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0;
|
||||
const hasContexts = () => !!personalContext() || store.subscribedBuckets.length > 0 || store.supervisedContexts.length > 0;
|
||||
|
||||
return (
|
||||
<Show when={hasContexts()}>
|
||||
@@ -197,9 +196,9 @@ export const ContextSwitcher: Component<{
|
||||
<Show when={props.isMobile} fallback={
|
||||
<>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
||||
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
|
||||
<div class={cn(
|
||||
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
||||
isPersonalContext() ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
|
||||
)}>
|
||||
<div class="w-2 h-2 rounded-full bg-current" />
|
||||
</div>
|
||||
@@ -209,8 +208,8 @@ export const ContextSwitcher: Component<{
|
||||
</>
|
||||
}>
|
||||
<div class="relative">
|
||||
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", ctx() !== 'mine' && "text-orange-500")} />
|
||||
{ctx() !== 'mine' && (
|
||||
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", !isPersonalContext() && "text-orange-500")} />
|
||||
{!isPersonalContext() && (
|
||||
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
|
||||
)}
|
||||
</div>
|
||||
@@ -223,7 +222,7 @@ export const ContextSwitcher: Component<{
|
||||
onClick={() => handleSwitch('mine', '', 'mine')}
|
||||
class={cn(
|
||||
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
||||
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
|
||||
isPersonalContext() ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
|
||||
@@ -260,31 +259,32 @@ export const ContextSwitcher: Component<{
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
|
||||
<Show when={store.supervisedContexts.length > 0}>
|
||||
<div class="py-1">
|
||||
<div class="h-px bg-border/50" />
|
||||
</div>
|
||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||
<span>Shared User Buckets</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
|
||||
<span>Supervised Contexts</span>
|
||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.supervisedContexts.length}</span>
|
||||
</div>
|
||||
<For each={oversightUsers()}>
|
||||
{(user) => (
|
||||
<For each={store.supervisedContexts}>
|
||||
{(supervised) => (
|
||||
<button
|
||||
onClick={() => handleSwitch(user.id, user.name, 'user')}
|
||||
onClick={() => handleSwitch(supervised.ownerUserId, supervised.ownerName, 'user')}
|
||||
class={cn(
|
||||
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
||||
(ctx() as any).userId === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
|
||||
(ctx() as any).userId === supervised.ownerUserId ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||
<div class="w-2 h-2 rounded-full bg-orange-500" />
|
||||
</div>
|
||||
<span class="truncate">{user.name}</span>
|
||||
<span class="truncate">{supervised.ownerName}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -224,7 +224,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
||||
}
|
||||
} else if (typeof ctx === 'object') {
|
||||
if ('bucketId' in ctx) {
|
||||
const bucketName = ctx.name;
|
||||
const bucketName = store.contexts.find(context => context.id === ctx.bucketId)?.displayName || ctx.name;
|
||||
if (bucketName) {
|
||||
const tag = `@${bucketName}`;
|
||||
if (!finalTags.includes(tag)) finalTags.push(tag);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTheme } from "./ThemeProvider";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tag, Plus } from "lucide-solid";
|
||||
import { toast } from "solid-sonner";
|
||||
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey } from "@/lib/tags";
|
||||
|
||||
interface TagPickerProps {
|
||||
selectedTags: string[];
|
||||
@@ -24,11 +26,11 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
|
||||
const allTagNames = () => {
|
||||
const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
|
||||
const bucketTags = store.buckets.map(b => `@${b.name}`);
|
||||
const contextTags = store.contexts.filter(c => !c.deletedAt).map(c => buildShareTag(c.displayName));
|
||||
const noteTags = store.notes
|
||||
.filter(n => !(n.tags || []).some(t => t.startsWith("child-of-")))
|
||||
.map(n => `#${n.title}`);
|
||||
return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort();
|
||||
.map(n => buildNoteTag(n.key || n.title));
|
||||
return [...new Set([...definedTags, ...contextTags, ...noteTags])].sort();
|
||||
};
|
||||
|
||||
const suggestedTags = () => {
|
||||
@@ -53,10 +55,28 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
||||
const name = selectedTagName().trim();
|
||||
if (!name) return;
|
||||
|
||||
if (name.startsWith("@")) {
|
||||
const exists = store.contexts.some(context => context.key === normalizeContextKey(name));
|
||||
if (!exists) {
|
||||
toast.error("Create the shared context first before tagging tasks with it.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name.startsWith("#")) {
|
||||
const exists = store.notes.some(note => note.key === normalizeNoteKey(name));
|
||||
if (!exists) {
|
||||
toast.error("Create the canonical note first before linking it with a #tag.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// update global definition
|
||||
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
|
||||
const color = bucket ? bucket.color : undefined;
|
||||
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
|
||||
if (!name.startsWith("@") && !name.startsWith("#")) {
|
||||
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
|
||||
}
|
||||
|
||||
// Toggle in current task selection if not already there
|
||||
const currentSelected = props.selectedTags;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { getStatusOptionsForTask } from "@/lib/constants";
|
||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
||||
import { buildShareTag } from "@/lib/tags";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
|
||||
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
||||
@@ -33,9 +34,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase();
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||
}
|
||||
|
||||
@@ -139,23 +139,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
||||
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
|
||||
</div>
|
||||
|
||||
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
|
||||
{/* Shared Indicator - driven by @tag share contexts */}
|
||||
{(() => {
|
||||
const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0;
|
||||
// Check if any tag-based ShareRules match this task's tags (exclude 'all' type)
|
||||
const hasTagRuleShares = store.shareRules.some(rule =>
|
||||
rule.type === 'tag' &&
|
||||
rule.tagName &&
|
||||
props.task.tags?.includes(rule.tagName)
|
||||
);
|
||||
const isShared = hasIndividualShares || hasTagRuleShares;
|
||||
if (props.task.shareRefs.length === 0) return null;
|
||||
|
||||
if (!isShared) return null;
|
||||
|
||||
const shareCount = (props.task.sharedWith?.length || 0);
|
||||
const tooltip = hasIndividualShares
|
||||
? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}`
|
||||
: 'Shared via tag rule';
|
||||
const tooltip = props.task.shareRefs.length === 1
|
||||
? 'Shared via 1 @context'
|
||||
: `Shared via ${props.task.shareRefs.length} @contexts`;
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
|
||||
|
||||
+33
-148
@@ -1,9 +1,9 @@
|
||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings, Star, FileText } from "lucide-solid";
|
||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid";
|
||||
import { StatusCircle } from "./StatusCircle";
|
||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -16,8 +16,8 @@ import { toast } from "solid-sonner";
|
||||
import { lazy, Suspense } from "solid-js";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||
import { pb } from "@/lib/pocketbase";
|
||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
||||
import { buildShareTag } from "@/lib/tags";
|
||||
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
||||
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
||||
|
||||
@@ -174,7 +174,8 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
|
||||
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
const bucketTagName = `@${(ctx as { name: string }).name}`;
|
||||
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name);
|
||||
// Check case-insensitive existence
|
||||
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
|
||||
newTags.push(bucketTagName);
|
||||
@@ -201,9 +202,8 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
const ctx = currentTaskContext();
|
||||
|
||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
||||
// Buckets are now tagged with '@' + name.
|
||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
||||
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase();
|
||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||
}
|
||||
|
||||
@@ -682,11 +682,32 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
||||
{/* Sharing Section */}
|
||||
<div class="p-2 border-t border-border/50">
|
||||
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
||||
<UserSharePicker
|
||||
taskId={props.task.id}
|
||||
sharedWith={props.task.sharedWith || []}
|
||||
tags={props.task.tags || []}
|
||||
/>
|
||||
<div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2">
|
||||
<p class="text-[0.6875rem] text-muted-foreground leading-relaxed">
|
||||
Sharing is driven by the task's <span class="font-semibold text-foreground">@tags</span>. Add or remove
|
||||
<span class="font-semibold text-foreground"> @people</span> and <span class="font-semibold text-foreground">@buckets</span> in the tag list above.
|
||||
</p>
|
||||
<Show
|
||||
when={props.task.shareRefs.length > 0}
|
||||
fallback={<p class="text-[0.6875rem] text-muted-foreground/70">No shared contexts on this task.</p>}
|
||||
>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={props.task.shareRefs}>
|
||||
{(ref) => {
|
||||
const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
|
||||
const label = `@${context?.displayName || ref.key}`;
|
||||
const policy = context?.policy || (ref.kind === "bucket" ? "handoff" : "collaborative");
|
||||
return (
|
||||
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
|
||||
<span>{label}</span>
|
||||
<span class="uppercase text-[0.5rem] tracking-wider text-muted-foreground">{policy}</span>
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -757,139 +778,3 @@ const MetadataItem: Component<{
|
||||
);
|
||||
};
|
||||
|
||||
// User Share Picker Component
|
||||
const UserSharePicker: Component<{
|
||||
taskId: string;
|
||||
sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>;
|
||||
tags: string[];
|
||||
}> = (props) => {
|
||||
const [users, setUsers] = createSignal<Array<{ id: string; name: string }>>([]);
|
||||
const [selectedUserId, setSelectedUserId] = createSignal<string>("");
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
// Get matching ShareRules for this task
|
||||
const matchingRules = () => {
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
return store.shareRules.filter(rule => {
|
||||
if (rule.ownerId !== currentUserId) return false;
|
||||
if (rule.type === 'all') return true;
|
||||
if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const getUserName = (userId: string) => {
|
||||
return users().find(u => u.id === userId)?.name || userId;
|
||||
};
|
||||
|
||||
// Fetch users on mount
|
||||
createEffect(async () => {
|
||||
try {
|
||||
const records = await pb.collection('users').getFullList({
|
||||
fields: 'id,name'
|
||||
});
|
||||
setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id })));
|
||||
} catch (err) {
|
||||
console.error("Failed to load users:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const availableUsers = () => {
|
||||
const sharedUserIds = props.sharedWith.map(s => s.userId);
|
||||
const currentUserId = pb.authStore.model?.id;
|
||||
return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex gap-1 mb-2">
|
||||
<Select<{ id: string; name: string }>
|
||||
value={users().find(u => u.id === selectedUserId()) || null}
|
||||
onChange={(user) => setSelectedUserId(user?.id || "")}
|
||||
options={availableUsers()}
|
||||
optionValue="id"
|
||||
optionTextValue="name"
|
||||
placeholder={loading() ? "Loading..." : "Select user..."}
|
||||
itemComponent={(itemProps: any) => (
|
||||
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
|
||||
)}
|
||||
>
|
||||
<SelectTrigger class="flex-1 h-8 text-xs">
|
||||
<SelectValue<{ id: string; name: string }>>
|
||||
{(state) => state.selectedOption()?.name || "Select user..."}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent />
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={!selectedUserId()}
|
||||
onClick={async () => {
|
||||
if (selectedUserId()) {
|
||||
await shareTask(props.taskId, selectedUserId(), 'edit');
|
||||
setSelectedUserId("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<For each={props.sharedWith}>
|
||||
{(share) => (
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded-lg bg-muted/30 mb-1">
|
||||
<span class="text-xs truncate flex-1">{getUserName(share.userId)}</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 hover:bg-amber-500/10 hover:text-amber-600"
|
||||
title="Split - Give them an independent copy"
|
||||
onClick={() => splitTask(props.taskId, share.userId)}
|
||||
>
|
||||
<GitBranch size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
|
||||
title="Revoke access"
|
||||
onClick={() => revokeShare(props.taskId, share.userId)}
|
||||
>
|
||||
<UserMinus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
{/* ShareRule-based sharing */}
|
||||
{matchingRules().length > 0 && (
|
||||
<div class="mt-3 pt-3 border-t border-border/30">
|
||||
<div class="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40 mb-2 px-1">Automatic Rule Shares</div>
|
||||
<For each={matchingRules()}>
|
||||
{(rule) => (
|
||||
<div class="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-muted/10 mb-1 border border-border/5">
|
||||
<div class="shrink-0 text-muted-foreground/40">
|
||||
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-[0.6875rem] font-medium leading-none truncate">
|
||||
{getUserName(rule.targetUserId)}
|
||||
</span>
|
||||
<span class="text-[9px] text-muted-foreground/50 mt-0.5 uppercase tracking-tighter">
|
||||
{rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user