fixed sharing

This commit is contained in:
2026-02-05 18:43:14 -06:00
parent 120484d54e
commit f783c20f60
4 changed files with 689 additions and 23 deletions
+26 -1
View File
@@ -1,7 +1,7 @@
import { type Component, createMemo } from "solid-js";
import { type Task, toggleTask, calculateUrgencyFromDate, setActiveTaskId, store, copyTask } from "@/store";
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 { For } from "solid-js";
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>
</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 */}
{props.task.tags && props.task.tags.length > 0 && (
<div class="flex flex-wrap items-center gap-1 min-w-0">
+150 -2
View File
@@ -1,9 +1,9 @@
import { type Component, createEffect, createSignal, For } from "solid-js";
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 { 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 { Button } from "./ui/button";
import { TagPicker } from "./TagPicker";
@@ -15,6 +15,7 @@ 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";
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>
</Button>
</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>
</PopoverContent>
</Popover>
@@ -536,3 +547,140 @@ const MetadataItem: Component<{
</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>
)}
</>
);
};
+353 -15
View File
@@ -33,6 +33,7 @@ export interface Task {
lastUncompleted?: string; // ISO date of last automatic reset
};
size?: number; // 0-10, task complexity/size
sharedWith?: Array<{ userId: string; access: 'view' | 'edit' }>; // Users this task is shared with
}
export const checkRecurringTasks = () => {
@@ -139,6 +140,14 @@ export interface Filter {
editedToday: boolean;
}
export interface ShareRule {
id: string;
ownerId: string;
targetUserId: string;
type: 'tag' | 'all';
tagName?: string;
}
interface TaskStore {
tasks: Task[];
pWeight: number;
@@ -148,6 +157,7 @@ interface TaskStore {
tagDefinitions: TagDefinition[];
templates: TaskTemplate[];
filter: Filter;
shareRules: ShareRule[];
}
// Initial empty state
@@ -166,7 +176,8 @@ export const [store, setStore] = createStore<TaskStore>({
urgencyMin: 1,
urgencyMax: 10,
editedToday: false
}
},
shareRules: []
});
export const matchesFilter = (task: Task) => {
@@ -310,10 +321,45 @@ const mapRecordToTask = (r: any): Task => {
created: r.created,
updated: r.updated,
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 () => {
// Unsubscribe first to avoid duplicates
await pb.collection(TASGRID_COLLECTION).unsubscribe('*');
@@ -325,9 +371,6 @@ export const subscribeToRealtime = async () => {
// Check if it's a template
const isTemplate = e.record.tags?.includes("__template__");
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 = {};
try { meta = JSON.parse(e.record.content || "{}"); } catch (e) { }
const newTemplate: TaskTemplate = {
@@ -343,13 +386,19 @@ export const subscribeToRealtime = async () => {
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);
if (!exists) {
const currentUserId = pb.authStore.model?.id;
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]);
}
}
}
if (e.action === 'update') {
// Is it a template?
@@ -369,23 +418,36 @@ export const subscribeToRealtime = async () => {
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);
if (existingTask) {
const isOwnTask = e.record.user === currentUserId;
const incomingUpdated = new Date(e.record.updated).getTime();
if (existingTask) {
const localUpdated = new Date(existingTask.updated).getTime();
// Only apply update if incoming is newer (or we don't have a timestamp)
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);
setStore("tasks", t => t.id === e.record.id, updatedTask);
} else {
console.log("Skipping stale realtime update for task:", e.record.id);
}
} else {
// Task doesn't exist locally, add it
const updatedTask = mapRecordToTask(e.record);
setStore("tasks", t => [updatedTask, ...t]);
// Task doesn't exist locally - check if it should be visible now
if (currentUserId && shouldTaskBeVisible(e.record, currentUserId)) {
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));
}
});
// 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 () => {
@@ -456,16 +579,44 @@ export const initStore = async () => {
console.warn("Failed to load tags:", tagErr);
}
// 2. Fetch Tasks & Templates
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
filter: `user = "${pb.authStore.model?.id}"`,
// 2. Fetch Tasks & Templates + Shared Tasks in PARALLEL
const currentUserId = 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',
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 loadedTemplates: TaskTemplate[] = [];
const seenIds = new Set<string>();
records.forEach(r => {
// Process own tasks & templates
ownRecords.forEach(r => {
const tags = r.tags || [];
if (tags.includes("__template__")) {
let meta: any = {};
@@ -481,15 +632,72 @@ export const initStore = async () => {
});
} else {
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("templates", loadedTemplates);
// Check recurring tasks after load
checkRecurringTasks();
// 2.7. Fetch Share Rules (owned by current user)
await loadShareRules();
// 3. Subscribe to Realtime Updates
await subscribeToRealtime();
@@ -931,6 +1139,136 @@ export const setFilter = (update: Partial<Filter>) => {
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 = () => {
setStore("filter", {
query: "",
+158 -3
View File
@@ -1,15 +1,16 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
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 { 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 { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
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 { pb } from "@/lib/pocketbase";
import { createEffect } from "solid-js";
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 [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
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 (
<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>
</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 */}
<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">