fixed sharing
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { type Component, createMemo } from "solid-js";
|
import { type Component, createMemo } from "solid-js";
|
||||||
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } from "@/store";
|
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } from "@/store";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy } from "lucide-solid";
|
import { CheckCircle2, Circle, Clock, ArrowUpCircle, Copy, Users2 } from "lucide-solid";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { For } from "solid-js";
|
import { For } from "solid-js";
|
||||||
import { useTheme } from "./ThemeProvider";
|
import { useTheme } from "./ThemeProvider";
|
||||||
@@ -78,6 +78,31 @@ export const TaskCard: Component<{ task: Task }> = (props) => {
|
|||||||
<span class="text-[10px] font-medium">P{props.task.priority}</span>
|
<span class="text-[10px] font-medium">P{props.task.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
|
||||||
|
{(() => {
|
||||||
|
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 (!isShared) return null;
|
||||||
|
|
||||||
|
const shareCount = (props.task.sharedWith?.length || 0);
|
||||||
|
const tooltip = hasIndividualShares
|
||||||
|
? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}`
|
||||||
|
: 'Shared via tag rule';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
|
||||||
|
<Users2 size={11} class="text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{props.task.tags && props.task.tags.length > 0 && (
|
{props.task.tags && props.task.tags.length > 0 && (
|
||||||
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { type Component, createEffect, createSignal, For } from "solid-js";
|
import { type Component, createEffect, createSignal, For } from "solid-js";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask } from "@/store";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge } from "lucide-solid";
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { TagPicker } from "./TagPicker";
|
import { TagPicker } from "./TagPicker";
|
||||||
@@ -15,6 +15,7 @@ import { toast } from "solid-sonner";
|
|||||||
import { lazy, Suspense } from "solid-js";
|
import { lazy, Suspense } from "solid-js";
|
||||||
import { useTheme } from "./ThemeProvider";
|
import { useTheme } from "./ThemeProvider";
|
||||||
import { getThemeAdjustedColor } from "@/lib/colors";
|
import { getThemeAdjustedColor } from "@/lib/colors";
|
||||||
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
|
||||||
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||||
|
|
||||||
@@ -468,6 +469,16 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
<span class="text-xs">Create Template from Task</span>
|
<span class="text-xs">Create Template from Task</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sharing Section */}
|
||||||
|
<div class="p-2 border-t border-border/50">
|
||||||
|
<div class="text-[10px] 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>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -536,3 +547,140 @@ const MetadataItem: Component<{
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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-[11px] 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+355
-17
@@ -33,6 +33,7 @@ export interface Task {
|
|||||||
lastUncompleted?: string; // ISO date of last automatic reset
|
lastUncompleted?: string; // ISO date of last automatic reset
|
||||||
};
|
};
|
||||||
size?: number; // 0-10, task complexity/size
|
size?: number; // 0-10, task complexity/size
|
||||||
|
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkRecurringTasks = () => {
|
export const checkRecurringTasks = () => {
|
||||||
@@ -139,6 +140,14 @@ export interface Filter {
|
|||||||
editedToday: boolean;
|
editedToday: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShareRule {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
targetUserId: string;
|
||||||
|
type: 'tag' | 'all';
|
||||||
|
tagName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskStore {
|
interface TaskStore {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
pWeight: number;
|
pWeight: number;
|
||||||
@@ -148,6 +157,7 @@ interface TaskStore {
|
|||||||
tagDefinitions: TagDefinition[];
|
tagDefinitions: TagDefinition[];
|
||||||
templates: TaskTemplate[];
|
templates: TaskTemplate[];
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
|
shareRules: ShareRule[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial empty state
|
// Initial empty state
|
||||||
@@ -166,7 +176,8 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
urgencyMin: 1,
|
urgencyMin: 1,
|
||||||
urgencyMax: 10,
|
urgencyMax: 10,
|
||||||
editedToday: false
|
editedToday: false
|
||||||
}
|
},
|
||||||
|
shareRules: []
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matchesFilter = (task: Task) => {
|
export const matchesFilter = (task: Task) => {
|
||||||
@@ -310,10 +321,45 @@ const mapRecordToTask = (r: any): Task => {
|
|||||||
created: r.created,
|
created: r.created,
|
||||||
updated: r.updated,
|
updated: r.updated,
|
||||||
recurrence: r.recurrence,
|
recurrence: r.recurrence,
|
||||||
size: (r.size === null || r.size === undefined) ? undefined : r.size
|
size: (r.size === null || r.size === undefined) ? undefined : r.size,
|
||||||
|
sharedWith: r.sharedWith
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mapRecordToShareRule = (r: any): ShareRule => {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
ownerId: r.ownerId,
|
||||||
|
targetUserId: r.targetUserId,
|
||||||
|
type: r.type as 'tag' | 'all',
|
||||||
|
tagName: r.tagName
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to check if a task should be visible based on ownership, shares, or rules
|
||||||
|
const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
||||||
|
// Own task
|
||||||
|
if (task.user === currentUserId) return true;
|
||||||
|
|
||||||
|
// Individually shared
|
||||||
|
const sharedWith = task.sharedWith || [];
|
||||||
|
if (sharedWith.some((s: any) => s.userId === currentUserId)) return true;
|
||||||
|
|
||||||
|
// ShareRule match
|
||||||
|
const matchingRule = store.shareRules.find(rule => {
|
||||||
|
if (rule.targetUserId !== currentUserId) return false;
|
||||||
|
if (task.user !== rule.ownerId) return false;
|
||||||
|
if (rule.type === 'all') return true;
|
||||||
|
if (rule.type === 'tag') {
|
||||||
|
const tags = task.tags || [];
|
||||||
|
return tags.includes(rule.tagName);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!matchingRule;
|
||||||
|
};
|
||||||
|
|
||||||
export const subscribeToRealtime = async () => {
|
export const subscribeToRealtime = async () => {
|
||||||
// Unsubscribe first to avoid duplicates
|
// Unsubscribe first to avoid duplicates
|
||||||
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
|
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
|
||||||
@@ -325,9 +371,6 @@ export const subscribeToRealtime = async () => {
|
|||||||
// Check if it's a template
|
// Check if it's a template
|
||||||
const isTemplate = e.record.tags?.includes("__template__");
|
const isTemplate = e.record.tags?.includes("__template__");
|
||||||
if (isTemplate) {
|
if (isTemplate) {
|
||||||
// We don't auto-add templates to the main task list usually?
|
|
||||||
// Wait, `store.templates` is separate.
|
|
||||||
// We should handle templates too if we want sync on templates.
|
|
||||||
let meta: any = {};
|
let meta: any = {};
|
||||||
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
|
||||||
const newTemplate: TaskTemplate = {
|
const newTemplate: TaskTemplate = {
|
||||||
@@ -343,11 +386,17 @@ export const subscribeToRealtime = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular Task
|
// Regular Task - check if already exists and if it should be visible
|
||||||
const exists = store.tasks.find(t => t.id === e.record.id);
|
const exists = store.tasks.find(t => t.id === e.record.id);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
const newTask = mapRecordToTask(e.record);
|
const currentUserId = pb.authStore.model?.id;
|
||||||
setStore("tasks", t => [newTask, ...t]);
|
const isOwnTask = e.record.user === currentUserId;
|
||||||
|
|
||||||
|
// Only add if it's own task or should be visible via sharing
|
||||||
|
if (isOwnTask || (currentUserId && shouldTaskBeVisible(e.record, currentUserId))) {
|
||||||
|
const newTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => [newTask, ...t]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,23 +418,36 @@ export const subscribeToRealtime = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal Task - only apply if incoming record is newer or equal
|
const currentUserId = pb.authStore.model?.id;
|
||||||
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
const existingTask = store.tasks.find(t => t.id === e.record.id);
|
||||||
|
const isOwnTask = e.record.user === currentUserId;
|
||||||
|
const incomingUpdated = new Date(e.record.updated).getTime();
|
||||||
|
|
||||||
if (existingTask) {
|
if (existingTask) {
|
||||||
const incomingUpdated = new Date(e.record.updated).getTime();
|
|
||||||
const localUpdated = new Date(existingTask.updated).getTime();
|
const localUpdated = new Date(existingTask.updated).getTime();
|
||||||
|
|
||||||
// Only apply update if incoming is newer (or we don't have a timestamp)
|
// Only apply update if incoming is newer (or we don't have a timestamp)
|
||||||
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
if (!existingTask.updated || incomingUpdated >= localUpdated) {
|
||||||
|
// For shared tasks, re-evaluate visibility (e.g., tags may have changed)
|
||||||
|
if (!isOwnTask && currentUserId) {
|
||||||
|
if (!shouldTaskBeVisible(e.record, currentUserId)) {
|
||||||
|
// No longer visible, remove it
|
||||||
|
setStore("tasks", t => t.filter(x => x.id !== e.record.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updatedTask = mapRecordToTask(e.record);
|
const updatedTask = mapRecordToTask(e.record);
|
||||||
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
setStore("tasks", t => t.id === e.record.id, updatedTask);
|
||||||
} else {
|
} else {
|
||||||
console.log("Skipping stale realtime update for task:", e.record.id);
|
console.log("Skipping stale realtime update for task:", e.record.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Task doesn't exist locally, add it
|
// Task doesn't exist locally - check if it should be visible now
|
||||||
const updatedTask = mapRecordToTask(e.record);
|
if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) {
|
||||||
setStore("tasks", t => [updatedTask, ...t]);
|
const newTask = mapRecordToTask(e.record);
|
||||||
|
setStore("tasks", t => [newTask, ...t]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +457,67 @@ export const subscribeToRealtime = async () => {
|
|||||||
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
setStore("templates", t => (t || []).filter(x => x.id !== e.record.id));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscribe to Share Rules
|
||||||
|
await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*');
|
||||||
|
pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => {
|
||||||
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
|
||||||
|
if (e.action === 'create') {
|
||||||
|
const newRule = mapRecordToShareRule(e.record);
|
||||||
|
|
||||||
|
// Add to shareRules store
|
||||||
|
setStore("shareRules", (rules) => {
|
||||||
|
if (rules.some(r => r.id === newRule.id)) return rules;
|
||||||
|
return [...rules, newRule];
|
||||||
|
});
|
||||||
|
|
||||||
|
// If I'm the target, fetch matching tasks from the owner
|
||||||
|
if (newRule.targetUserId === currentUserId) {
|
||||||
|
try {
|
||||||
|
let filter = `user = "${newRule.ownerId}"`;
|
||||||
|
if (newRule.type === 'tag' && newRule.tagName) {
|
||||||
|
filter += ` && tags ~ "${newRule.tagName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter,
|
||||||
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
|
});
|
||||||
|
|
||||||
|
records.forEach((r: any) => {
|
||||||
|
if (!r.tags?.includes("__template__") && !store.tasks.find(t => t.id === r.id)) {
|
||||||
|
const newTask = mapRecordToTask(r);
|
||||||
|
setStore("tasks", t => [newTask, ...t]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to fetch tasks for new share rule:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.action === 'delete') {
|
||||||
|
const deletedRuleId = e.record.id;
|
||||||
|
const deletedRule = store.shareRules.find(r => r.id === deletedRuleId);
|
||||||
|
|
||||||
|
// Remove from shareRules store
|
||||||
|
setStore("shareRules", (rules) => rules.filter(r => r.id !== deletedRuleId));
|
||||||
|
|
||||||
|
// If I was the target, remove tasks that are no longer visible
|
||||||
|
if (deletedRule && deletedRule.targetUserId === currentUserId) {
|
||||||
|
setStore("tasks", tasks =>
|
||||||
|
tasks.filter(task => shouldTaskBeVisible(task, currentUserId!))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.action === 'update') {
|
||||||
|
const updatedRule = mapRecordToShareRule(e.record);
|
||||||
|
setStore("shareRules", (rules) => rules.map(r => r.id === updatedRule.id ? updatedRule : r));
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initStore = async () => {
|
export const initStore = async () => {
|
||||||
@@ -456,16 +579,44 @@ export const initStore = async () => {
|
|||||||
console.warn("Failed to load tags:", tagErr);
|
console.warn("Failed to load tags:", tagErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch Tasks & Templates
|
// 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL
|
||||||
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
const currentUserId = pb.authStore.model?.id;
|
||||||
filter: `user = "${pb.authStore.model?.id}"`,
|
|
||||||
|
// Prepare parallel fetch promises (requestKey: null prevents auto-cancellation)
|
||||||
|
const ownTasksPromise = pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter: `user = "${currentUserId}"`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const individuallySharedPromise = currentUserId
|
||||||
|
? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter: `sharedWith ~ "${currentUserId}"`,
|
||||||
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
|
}).catch(err => { console.warn("Failed to load individually shared tasks:", err); return []; })
|
||||||
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
const shareRulesPromise = currentUserId
|
||||||
|
? pb.collection(SHARE_RULES_COLLECTION).getFullList({
|
||||||
|
filter: `targetUserId = "${currentUserId}"`,
|
||||||
|
requestKey: null
|
||||||
|
}).catch(err => { console.warn("Failed to load share rules:", err); return []; })
|
||||||
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
// Wait for all initial fetches
|
||||||
|
const [ownRecords, individuallySharedRecords, incomingRules] = await Promise.all([
|
||||||
|
ownTasksPromise,
|
||||||
|
individuallySharedPromise,
|
||||||
|
shareRulesPromise
|
||||||
|
]);
|
||||||
|
|
||||||
const allTasks: Task[] = [];
|
const allTasks: Task[] = [];
|
||||||
const loadedTemplates: TaskTemplate[] = [];
|
const loadedTemplates: TaskTemplate[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
records.forEach(r => {
|
// Process own tasks & templates
|
||||||
|
ownRecords.forEach(r => {
|
||||||
const tags = r.tags || [];
|
const tags = r.tags || [];
|
||||||
if (tags.includes("__template__")) {
|
if (tags.includes("__template__")) {
|
||||||
let meta: any = {};
|
let meta: any = {};
|
||||||
@@ -481,15 +632,72 @@ export const initStore = async () => {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
allTasks.push(mapRecordToTask(r));
|
allTasks.push(mapRecordToTask(r));
|
||||||
|
seenIds.add(r.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Process individually shared tasks
|
||||||
|
individuallySharedRecords.forEach((r: any) => {
|
||||||
|
if (!seenIds.has(r.id)) {
|
||||||
|
const tags = r.tags || [];
|
||||||
|
if (!tags.includes("__template__")) {
|
||||||
|
allTasks.push(mapRecordToTask(r));
|
||||||
|
seenIds.add(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process ShareRules - fetch tasks from each owner in parallel
|
||||||
|
if (incomingRules.length > 0) {
|
||||||
|
// Group rules by owner
|
||||||
|
const ownerRules = new Map<string, { type: 'all' | 'tag', tagName?: string }[]>();
|
||||||
|
incomingRules.forEach((rule: any) => {
|
||||||
|
const existing = ownerRules.get(rule.ownerId) || [];
|
||||||
|
existing.push({ type: rule.type, tagName: rule.tagName });
|
||||||
|
ownerRules.set(rule.ownerId, existing);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch from all owners in parallel
|
||||||
|
const ownerFetchPromises = Array.from(ownerRules.entries()).map(async ([ownerId, rules]) => {
|
||||||
|
const hasAllRule = rules.some(r => r.type === 'all');
|
||||||
|
const tagRules = rules.filter(r => r.type === 'tag').map(r => r.tagName).filter(Boolean);
|
||||||
|
|
||||||
|
let filter = `user = "${ownerId}"`;
|
||||||
|
if (!hasAllRule && tagRules.length > 0) {
|
||||||
|
const tagFilters = tagRules.map(t => `tags ~ "${t}"`).join(' || ');
|
||||||
|
filter += ` && (${tagFilters})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await pb.collection(TASGRID_COLLECTION).getFullList({ filter, sort: '-created', requestKey: null });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to fetch tasks from owner ${ownerId}:`, err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerResults = await Promise.all(ownerFetchPromises);
|
||||||
|
ownerResults.flat().forEach((r: any) => {
|
||||||
|
if (!seenIds.has(r.id)) {
|
||||||
|
const tags = r.tags || [];
|
||||||
|
if (!tags.includes("__template__")) {
|
||||||
|
allTasks.push(mapRecordToTask(r));
|
||||||
|
seenIds.add(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set store ONCE with all tasks
|
||||||
setStore("tasks", reconcile(allTasks));
|
setStore("tasks", reconcile(allTasks));
|
||||||
setStore("templates", loadedTemplates);
|
setStore("templates", loadedTemplates);
|
||||||
|
|
||||||
// Check recurring tasks after load
|
// Check recurring tasks after load
|
||||||
checkRecurringTasks();
|
checkRecurringTasks();
|
||||||
|
|
||||||
|
// 2.7. Fetch Share Rules (owned by current user)
|
||||||
|
await loadShareRules();
|
||||||
|
|
||||||
// 3. Subscribe to Realtime Updates
|
// 3. Subscribe to Realtime Updates
|
||||||
await subscribeToRealtime();
|
await subscribeToRealtime();
|
||||||
|
|
||||||
@@ -931,6 +1139,136 @@ export const setFilter = (update: Partial<Filter>) => {
|
|||||||
setStore("filter", (f) => ({ ...f, ...update }));
|
setStore("filter", (f) => ({ ...f, ...update }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// -- Sharing Functions --
|
||||||
|
|
||||||
|
export const shareTask = async (taskId: string, userId: string, access: 'view' | 'edit' = 'view') => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
const task = store.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const existingShares = task.sharedWith || [];
|
||||||
|
if (existingShares.some(s => s.userId === userId)) {
|
||||||
|
toast.info(`Task is already shared with this user`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newShares = [...existingShares, { userId, access }];
|
||||||
|
|
||||||
|
// Update task with new sharedWith
|
||||||
|
await updateTask(taskId, { sharedWith: newShares });
|
||||||
|
toast.success(`Task shared successfully`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeShare = async (taskId: string, userId: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
const task = store.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const existingShares = task.sharedWith || [];
|
||||||
|
const newShares = existingShares.filter(s => s.userId !== userId);
|
||||||
|
|
||||||
|
await updateTask(taskId, { sharedWith: newShares });
|
||||||
|
toast.success(`Share revoked`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const splitTask = async (taskId: string, userId: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
const task = store.tasks.find(t => t.id === taskId);
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a duplicate task owned by the target user
|
||||||
|
const duplicateData = {
|
||||||
|
user: userId,
|
||||||
|
title: task.title,
|
||||||
|
content: task.content,
|
||||||
|
priority: task.priority,
|
||||||
|
size: task.size,
|
||||||
|
tags: task.tags,
|
||||||
|
startDate: task.startDate,
|
||||||
|
dueDate: task.dueDate,
|
||||||
|
completed: task.completed,
|
||||||
|
deletedAt: null,
|
||||||
|
sharedWith: [], // Start fresh with no shares
|
||||||
|
recurrence: task.recurrence
|
||||||
|
};
|
||||||
|
|
||||||
|
await pb.collection(TASGRID_COLLECTION).create(duplicateData, { requestKey: null });
|
||||||
|
|
||||||
|
// Remove the user from the original task's sharedWith
|
||||||
|
const existingShares = task.sharedWith || [];
|
||||||
|
const newShares = existingShares.filter(s => s.userId !== userId);
|
||||||
|
await updateTask(taskId, { sharedWith: newShares });
|
||||||
|
|
||||||
|
toast.success(`Task split - user now has their own copy`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to split task:", err);
|
||||||
|
console.error("Error details:", err.response?.data || err.data || err.message);
|
||||||
|
toast.error("Failed to split task: " + (err.response?.data?.message || err.message || "Unknown error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Share Rules (for tag-based and all-tasks sharing) --
|
||||||
|
|
||||||
|
const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
||||||
|
|
||||||
|
export const loadShareRules = async () => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const records = await pb.collection(SHARE_RULES_COLLECTION).getFullList({
|
||||||
|
filter: `ownerId = "${pb.authStore.model?.id}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: ShareRule[] = records.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
ownerId: r.ownerId,
|
||||||
|
targetUserId: r.targetUserId,
|
||||||
|
type: r.type as 'tag' | 'all',
|
||||||
|
tagName: r.tagName
|
||||||
|
}));
|
||||||
|
|
||||||
|
setStore("shareRules", rules);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load share rules:", err);
|
||||||
|
// Collection might not exist yet, that's okay
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addShareRule = async (type: 'tag' | 'all', targetUserId: string, tagName?: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection(SHARE_RULES_COLLECTION).create({
|
||||||
|
ownerId: pb.authStore.model?.id,
|
||||||
|
targetUserId,
|
||||||
|
type,
|
||||||
|
tagName: type === 'tag' ? tagName : null
|
||||||
|
});
|
||||||
|
// Realtime subscription will handle adding to store
|
||||||
|
toast.success(`Share rule created`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create share rule:", err);
|
||||||
|
toast.error("Failed to create share rule. Make sure the collection exists in PocketBase.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeShareRule = async (ruleId: string) => {
|
||||||
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pb.collection(SHARE_RULES_COLLECTION).delete(ruleId);
|
||||||
|
setStore("shareRules", (rules) => rules.filter(r => r.id !== ruleId));
|
||||||
|
toast.success("Share rule removed");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete share rule:", err);
|
||||||
|
toast.error("Failed to delete share rule.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const clearFilter = () => {
|
export const clearFilter = () => {
|
||||||
setStore("filter", {
|
setStore("filter", {
|
||||||
query: "",
|
query: "",
|
||||||
|
|||||||
+158
-3
@@ -1,15 +1,16 @@
|
|||||||
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
|
||||||
import { ThemeToggle } from "../components/ThemeToggle";
|
import { ThemeToggle } from "../components/ThemeToggle";
|
||||||
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition } from "@/store";
|
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type } from "lucide-solid";
|
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag } from "lucide-solid";
|
||||||
import { TagPicker } from "@/components/TagPicker";
|
import { TagPicker } from "@/components/TagPicker";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
|
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
|
import { createEffect } from "solid-js";
|
||||||
|
|
||||||
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
|
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
|
||||||
|
|
||||||
@@ -18,7 +19,35 @@ export const SettingsView: Component = () => {
|
|||||||
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
|
const [isTagsOpen, setIsTagsOpen] = createSignal(false);
|
||||||
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
|
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
|
||||||
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
||||||
|
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
|
||||||
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
|
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
|
||||||
|
const [shareUserId, setShareUserId] = createSignal("");
|
||||||
|
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
|
||||||
|
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);
|
||||||
|
pb.collection('users').getFullList({ fields: 'id,name,email' })
|
||||||
|
.then(records => {
|
||||||
|
setAllUsers(records.map((r: any) => ({ id: r.id, name: r.name || r.email || r.id })));
|
||||||
|
})
|
||||||
|
.catch(err => console.error("Failed to load users:", err))
|
||||||
|
.finally(() => setUsersLoading(false));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getUserName = (userId: string) => {
|
||||||
|
return allUsers().find(u => u.id === userId)?.name || userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableUsers = () => {
|
||||||
|
const currentUserId = pb.authStore.model?.id;
|
||||||
|
return allUsers().filter(u => u.id !== currentUserId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
@@ -53,6 +82,132 @@ export const SettingsView: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Sharing */}
|
||||||
|
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between cursor-pointer group"
|
||||||
|
onClick={() => setIsSharingOpen(!isSharingOpen())}
|
||||||
|
>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Share2 size={16} />
|
||||||
|
Task Sharing
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Share all tasks or tasks with specific tags with others.</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} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isSharingOpen()}>
|
||||||
|
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
{/* 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">
|
||||||
|
<Button
|
||||||
|
variant={shareType() === 'all' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs gap-1"
|
||||||
|
onClick={() => setShareType('all')}
|
||||||
|
>
|
||||||
|
<Users size={12} />
|
||||||
|
All Tasks
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={shareType() === 'tag' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-xs gap-1"
|
||||||
|
onClick={() => setShareType('tag')}
|
||||||
|
>
|
||||||
|
<Tag size={12} />
|
||||||
|
By Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={shareType() === 'tag'}>
|
||||||
|
<Select
|
||||||
|
value={shareTag()}
|
||||||
|
onChange={(val) => val && setShareTag(val)}
|
||||||
|
options={store.tagDefinitions.map(t => t.name)}
|
||||||
|
placeholder="Select a tag"
|
||||||
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="h-9 text-sm">
|
||||||
|
<span>{shareTag() || "Select a tag..."}</span>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Select<{ id: string; name: string }>
|
||||||
|
value={allUsers().find(u => u.id === shareUserId()) || null}
|
||||||
|
onChange={(user) => setShareUserId(user?.id || "")}
|
||||||
|
options={availableUsers()}
|
||||||
|
optionValue="id"
|
||||||
|
optionTextValue="name"
|
||||||
|
placeholder={usersLoading() ? "Loading..." : "Select user..."}
|
||||||
|
itemComponent={(itemProps: any) => (
|
||||||
|
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SelectTrigger class="flex-1 h-9 text-sm">
|
||||||
|
<SelectValue<{ id: string; name: string }>>
|
||||||
|
{(state) => state.selectedOption()?.name || "Select user..."}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent />
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-9 gap-2"
|
||||||
|
disabled={!shareUserId()}
|
||||||
|
onClick={async () => {
|
||||||
|
if (shareUserId()) {
|
||||||
|
await addShareRule(shareType(), shareUserId(), shareType() === 'tag' ? shareTag() : undefined);
|
||||||
|
setShareUserId("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active share rules */}
|
||||||
|
<For each={store.shareRules} fallback={
|
||||||
|
<div class="text-center py-6 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||||
|
No active share rules.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(rule) => (
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
{rule.type === 'all' ? <Users size={14} class="text-primary shrink-0" /> : <Tag size={14} class="text-primary shrink-0" />}
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-medium truncate">{getUserName(rule.targetUserId)}</p>
|
||||||
|
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
{rule.type === 'all' ? 'All Tasks' : `Tag: ${rule.tagName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
onClick={() => removeShareRule(rule.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Matrix Configuration */}
|
{/* Matrix Configuration */}
|
||||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user