added buckets
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { type Component, For, Show } from "solid-js";
|
import { type Component, For, Show } from "solid-js";
|
||||||
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users } from "lucide-solid";
|
import { LayoutDashboard, ListTodo, Settings, Clock, ArrowUpCircle, PanelLeftClose, Snowflake, Pickaxe, ChevronDown, Gauge, TrendingUp, Users, Box } from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
@@ -162,11 +162,13 @@ export const ContextSwitcher: Component<{
|
|||||||
return c === 'mine' ? "My Workspace" : c.name;
|
return c === 'mine' ? "My Workspace" : c.name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitch = (userId: string, name: string) => {
|
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
||||||
if (userId === 'mine') {
|
if (type === 'mine') {
|
||||||
setCurrentTaskContext('mine');
|
setCurrentTaskContext('mine');
|
||||||
|
} else if (type === 'bucket') {
|
||||||
|
setCurrentTaskContext({ bucketId: id, name });
|
||||||
} else {
|
} else {
|
||||||
setCurrentTaskContext({ userId, name });
|
setCurrentTaskContext({ userId: id, name });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
@@ -214,7 +216,7 @@ export const ContextSwitcher: Component<{
|
|||||||
<PopoverContent class="w-[200px] p-1 bg-card border-border shadow-xl">
|
<PopoverContent class="w-[200px] p-1 bg-card border-border shadow-xl">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSwitch('mine', '')}
|
onClick={() => handleSwitch('mine', '', 'mine')}
|
||||||
class={cn(
|
class={cn(
|
||||||
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
"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"
|
ctx() === 'mine' ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
|
||||||
@@ -225,6 +227,36 @@ export const ContextSwitcher: Component<{
|
|||||||
</div>
|
</div>
|
||||||
My Workspace
|
My Workspace
|
||||||
</button>
|
</button>
|
||||||
|
<Show when={store.subscribedBuckets.length > 0}>
|
||||||
|
<div class="py-1">
|
||||||
|
<div class="h-px bg-border/50" />
|
||||||
|
</div>
|
||||||
|
<div class="px-2 py-1 text-[10px] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||||
|
<span>Buckets</span>
|
||||||
|
<span class="ml-auto bg-muted px-1.5 rounded text-[9px]">{store.subscribedBuckets.length}</span>
|
||||||
|
</div>
|
||||||
|
<For each={store.buckets.filter(b => store.subscribedBuckets.includes(b.id))}>
|
||||||
|
{(bucket) => (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSwitch(bucket.id, bucket.name, 'bucket')}
|
||||||
|
class={cn(
|
||||||
|
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
||||||
|
(ctx() as any).bucketId === bucket.id ? "bg-muted text-foreground font-semibold ring-1 ring-border" : "hover:bg-muted text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-4 h-4 rounded-md flex items-center justify-center text-white"
|
||||||
|
style={{ "background-color": bucket.color }}
|
||||||
|
>
|
||||||
|
<Box size={10} />
|
||||||
|
</div>
|
||||||
|
<span class="truncate">{bucket.name}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
<div class="h-px bg-border/50" />
|
<div class="h-px bg-border/50" />
|
||||||
</div>
|
</div>
|
||||||
@@ -235,7 +267,7 @@ export const ContextSwitcher: Component<{
|
|||||||
<For each={oversightUsers()}>
|
<For each={oversightUsers()}>
|
||||||
{(user) => (
|
{(user) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSwitch(user.id, user.name)}
|
onClick={() => handleSwitch(user.id, user.name, 'user')}
|
||||||
class={cn(
|
class={cn(
|
||||||
"w-full flex items-center gap-2 p-2 rounded-md text-sm transition-colors",
|
"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 === user.id ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
|
||||||
@@ -248,6 +280,7 @@ export const ContextSwitcher: Component<{
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ export const URGENCY_HOURS: Record<number, number> = {
|
|||||||
1: 4320
|
1: 4320
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const TASGRID_COLLECTION = 'TasGrid';
|
||||||
|
export const TAGS_COLLECTION = 'TasGrid_Tags';
|
||||||
|
export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
||||||
|
export const BUCKETS_COLLECTION = 'buckets';
|
||||||
|
|
||||||
export const SIZE_OPTIONS = [
|
export const SIZE_OPTIONS = [
|
||||||
{ value: "10", label: "10 - Multiple Days" },
|
{ value: "10", label: "10 - Multiple Days" },
|
||||||
{ value: "9", label: "9 - More than a day" },
|
{ value: "9", label: "9 - More than a day" },
|
||||||
|
|||||||
+219
-12
@@ -1,8 +1,8 @@
|
|||||||
import { createStore, reconcile } from "solid-js/store";
|
import { createStore, reconcile } from "solid-js/store";
|
||||||
import { createSignal, createEffect, createRoot } from "solid-js";
|
import { createSignal, createEffect, createRoot } from "solid-js";
|
||||||
import { pb, TASGRID_COLLECTION, TAGS_COLLECTION } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { toast } from "solid-sonner";
|
import { toast } from "solid-sonner";
|
||||||
import { URGENCY_HOURS } from "@/lib/constants";
|
import { TASGRID_COLLECTION, TAGS_COLLECTION, SHARE_RULES_COLLECTION, BUCKETS_COLLECTION, URGENCY_HOURS } from "@/lib/constants";
|
||||||
|
|
||||||
const getStorageKey = () => {
|
const getStorageKey = () => {
|
||||||
const userId = pb.authStore.model?.id;
|
const userId = pb.authStore.model?.id;
|
||||||
@@ -10,9 +10,10 @@ const getStorageKey = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const [now, setNow] = createSignal(Date.now());
|
export const [now, setNow] = createSignal(Date.now());
|
||||||
// Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user
|
// Context: 'mine' = My Workspace (default), { userId: string } = Oversight View for that user, { bucketId: string, name: string } = Bucket View
|
||||||
|
type TaskContext = 'mine' | { userId: string, name: string } | { bucketId: string, name: string };
|
||||||
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
export const [activeTaskId, setActiveTaskId] = createSignal<string | null>(null);
|
||||||
export const [currentTaskContext, setCurrentTaskContext] = createSignal<'mine' | { userId: string, name: string }>('mine');
|
export const [currentTaskContext, setCurrentTaskContext] = createSignal<TaskContext>('mine');
|
||||||
|
|
||||||
export type UrgencyLevel = number;
|
export type UrgencyLevel = number;
|
||||||
|
|
||||||
@@ -159,6 +160,13 @@ export interface ShareRule {
|
|||||||
tagName?: string;
|
tagName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Bucket {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskStore {
|
interface TaskStore {
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
pWeight: number;
|
pWeight: number;
|
||||||
@@ -170,6 +178,8 @@ interface TaskStore {
|
|||||||
filter: Filter;
|
filter: Filter;
|
||||||
shareRules: ShareRule[];
|
shareRules: ShareRule[];
|
||||||
filterTemplates: FilterTemplate[];
|
filterTemplates: FilterTemplate[];
|
||||||
|
buckets: Bucket[];
|
||||||
|
subscribedBuckets: string[]; // IDs of buckets I'm subscribed to
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial empty state
|
// Initial empty state
|
||||||
@@ -190,7 +200,9 @@ export const [store, setStore] = createStore<TaskStore>({
|
|||||||
editedToday: false
|
editedToday: false
|
||||||
},
|
},
|
||||||
shareRules: [],
|
shareRules: [],
|
||||||
filterTemplates: []
|
filterTemplates: [],
|
||||||
|
buckets: [],
|
||||||
|
subscribedBuckets: []
|
||||||
});
|
});
|
||||||
|
|
||||||
export const matchesFilter = (task: Task) => {
|
export const matchesFilter = (task: Task) => {
|
||||||
@@ -210,13 +222,34 @@ export const matchesFilter = (task: Task) => {
|
|||||||
task.tags?.includes(r.tagName || "")
|
task.tags?.includes(r.tagName || "")
|
||||||
);
|
);
|
||||||
|
|
||||||
// If context is "mine", we hide tasks that only match a "Share All" rule
|
// Bucket visibility logic for "My Workspace":
|
||||||
// (i.e., not owner, not explicit, not tag rule)
|
// Users requested: "Any user who adds that bucket to their workspaces list can view the workspace and see tasks in that bucket from all users."
|
||||||
|
// AND "In settings a user should be able to see and edit all buckets... They should be able to add any bucket to their workspaces list."
|
||||||
|
// When in 'mine', do we show bucket tasks?
|
||||||
|
// Typically 'My Workspace' is just MY stuff + stuff shared explicitly with me.
|
||||||
|
// Buckets seem to be separate "Contexts" in the sidebar.
|
||||||
|
// However, if the user wants them mixed in, they didn't explicitly say "mix into my main list",
|
||||||
|
// they said "add that bucket to their workspaces list". This usually implies a separate view.
|
||||||
|
// BUT, if I create a task and tag it "Shop", I expect to see it.
|
||||||
|
|
||||||
|
// If I own the task, I see it.
|
||||||
|
// If it's shared with me, I see it.
|
||||||
|
|
||||||
if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false;
|
if (!isOwner && !isExplicitlyShared && !isTagRuleShared) return false;
|
||||||
|
} else if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||||
|
// Bucket View
|
||||||
|
const bucketProxy = store.buckets.find(b => b.id === (ctx as { bucketId: string }).bucketId);
|
||||||
|
if (!bucketProxy) return false;
|
||||||
|
|
||||||
|
// Show tasks that have the bucket name as a tag
|
||||||
|
// The requirement: "if any user adds a tag that is the name of a bucket to a task that task is then made public and moved to the bucket."
|
||||||
|
if (!task.tags?.includes(bucketProxy.name)) return false;
|
||||||
} else {
|
} else {
|
||||||
// Context is Oversight for specific user
|
// Context is Oversight for specific user
|
||||||
// Show ONLY tasks owned by that user
|
// Show ONLY tasks owned by that user
|
||||||
if (task.ownerId !== ctx.userId) return false;
|
// Note: We might want to EXCLUDE bucket tasks from oversight if they are purely bucket tasks,
|
||||||
|
// but typically oversight means "everything that user owns".
|
||||||
|
if (task.ownerId !== (ctx as { userId: string }).userId) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide templates from all regular views
|
// Hide templates from all regular views
|
||||||
@@ -396,9 +429,28 @@ const shouldTaskBeVisible = (task: any, currentUserId: string): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bucket check
|
||||||
|
if (isTaskInSubscribedBucket(task)) return true;
|
||||||
|
|
||||||
return !!matchingRule;
|
return !!matchingRule;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if task is visible because it belongs to a subscribed bucket
|
||||||
|
const isTaskInSubscribedBucket = (task: any): boolean => {
|
||||||
|
const taskTags = task.tags || [];
|
||||||
|
if (taskTags.length === 0) return false;
|
||||||
|
|
||||||
|
// Check if any of the task's tags match a bucket we are subscribed to
|
||||||
|
// We need to look up bucket names from our subscribed bucket IDs
|
||||||
|
// This is slightly expensive if we iterate.
|
||||||
|
// Optimization: Pre-calculate subscribed bucket names?
|
||||||
|
// For now, simple find.
|
||||||
|
return store.subscribedBuckets.some(subId => {
|
||||||
|
const bucket = store.buckets.find(b => b.id === subId);
|
||||||
|
return bucket && taskTags.includes(bucket.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
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('*');
|
||||||
@@ -501,6 +553,32 @@ export const subscribeToRealtime = async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Subscribe to Buckets
|
||||||
|
// We need to know if buckets are added/removed/renamed
|
||||||
|
await pb.collection(BUCKETS_COLLECTION).unsubscribe('*');
|
||||||
|
pb.collection(BUCKETS_COLLECTION).subscribe('*', async (e) => {
|
||||||
|
if (e.action === 'create' || e.action === 'update') {
|
||||||
|
const bucket = {
|
||||||
|
id: e.record.id,
|
||||||
|
name: e.record.name,
|
||||||
|
color: e.record.color,
|
||||||
|
description: e.record.description
|
||||||
|
};
|
||||||
|
|
||||||
|
setStore("buckets", (prev) => {
|
||||||
|
const exists = prev.find(b => b.id === bucket.id);
|
||||||
|
if (exists) return prev.map(b => b.id === bucket.id ? bucket : b);
|
||||||
|
return [...prev, bucket];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.action === 'delete') {
|
||||||
|
setStore("buckets", (prev) => prev.filter(b => b.id !== e.record.id));
|
||||||
|
// Also remove from subscribed if present?
|
||||||
|
// The user record might still have it, but we can filter display.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Subscribe to Share Rules
|
// Subscribe to Share Rules
|
||||||
await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*');
|
await pb.collection(SHARE_RULES_COLLECTION).unsubscribe('*');
|
||||||
pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => {
|
pb.collection(SHARE_RULES_COLLECTION).subscribe('*', async (e) => {
|
||||||
@@ -598,13 +676,27 @@ export const initStore = async () => {
|
|||||||
pWeight: prefs.pWeight || 1.0,
|
pWeight: prefs.pWeight || 1.0,
|
||||||
uWeight: prefs.uWeight || 1.0,
|
uWeight: prefs.uWeight || 1.0,
|
||||||
matrixScaleDays: prefs.matrixScaleDays || 30,
|
matrixScaleDays: prefs.matrixScaleDays || 30,
|
||||||
prefId: userId
|
prefId: userId,
|
||||||
|
subscribedBuckets: user.subscribedBuckets || []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (prefErr) {
|
} catch (prefErr) {
|
||||||
console.warn("Failed to load preferences:", prefErr);
|
console.warn("Failed to load preferences:", prefErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1.2 Fetch Buckets
|
||||||
|
try {
|
||||||
|
const buckets = await pb.collection(BUCKETS_COLLECTION).getFullList({ sort: 'name' });
|
||||||
|
setStore("buckets", buckets.map((b: any) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
color: b.color,
|
||||||
|
description: b.description
|
||||||
|
})));
|
||||||
|
} catch (bucketErr) {
|
||||||
|
console.warn("Failed to load buckets (collection might not exist yet):", bucketErr);
|
||||||
|
}
|
||||||
|
|
||||||
// 1.5. Fetch Tag Definitions
|
// 1.5. Fetch Tag Definitions
|
||||||
try {
|
try {
|
||||||
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
|
const tagRecords = await pb.collection(TAGS_COLLECTION).getFullList({
|
||||||
@@ -650,11 +742,26 @@ export const initStore = async () => {
|
|||||||
}).catch(err => { console.warn("Failed to load share rules:", err); return []; })
|
}).catch(err => { console.warn("Failed to load share rules:", err); return []; })
|
||||||
: Promise.resolve([]);
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
// 1.9 Fetch Tasks for Subscribed Buckets
|
||||||
|
const subscribedBucketNames = store.subscribedBuckets
|
||||||
|
.map(id => store.buckets.find(b => b.id === id)?.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const bucketTasksPromise = (subscribedBucketNames.length > 0)
|
||||||
|
? pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter: subscribedBucketNames.map(name => `tags ~ "${name}"`).join(' || '),
|
||||||
|
sort: '-created',
|
||||||
|
requestKey: null
|
||||||
|
}).catch(err => { console.warn("Failed to load bucket tasks:", err); return []; })
|
||||||
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
|
||||||
// Wait for all initial fetches
|
// Wait for all initial fetches
|
||||||
const [ownRecords, individuallySharedRecords, incomingRules] = await Promise.all([
|
const [ownRecords, individuallySharedRecords, incomingRules, bucketRecords] = await Promise.all([
|
||||||
ownTasksPromise,
|
ownTasksPromise,
|
||||||
individuallySharedPromise,
|
individuallySharedPromise,
|
||||||
shareRulesPromise
|
shareRulesPromise,
|
||||||
|
bucketTasksPromise
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const allTasks: Task[] = [];
|
const allTasks: Task[] = [];
|
||||||
@@ -734,6 +841,19 @@ export const initStore = async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process Bucket Tasks
|
||||||
|
if (bucketRecords && bucketRecords.length > 0) {
|
||||||
|
bucketRecords.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 and rules
|
// Set store ONCE with all tasks and rules
|
||||||
setStore("tasks", reconcile(allTasks));
|
setStore("tasks", reconcile(allTasks));
|
||||||
setStore("templates", loadedTemplates);
|
setStore("templates", loadedTemplates);
|
||||||
@@ -777,6 +897,91 @@ export const initStore = async () => {
|
|||||||
|
|
||||||
// -- Actions --
|
// -- Actions --
|
||||||
|
|
||||||
|
export const toggleBucketSubscription = async (bucketId: string) => {
|
||||||
|
const currentSubscribed = store.subscribedBuckets;
|
||||||
|
const isSubscribed = currentSubscribed.includes(bucketId);
|
||||||
|
|
||||||
|
let newSubscribed = [];
|
||||||
|
if (isSubscribed) {
|
||||||
|
newSubscribed = currentSubscribed.filter(id => id !== bucketId);
|
||||||
|
} else {
|
||||||
|
newSubscribed = [...currentSubscribed, bucketId];
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("subscribedBuckets", newSubscribed);
|
||||||
|
|
||||||
|
// Persist to user profile
|
||||||
|
const userId = pb.authStore.model?.id;
|
||||||
|
if (userId) {
|
||||||
|
try {
|
||||||
|
await pb.collection('users').update(userId, {
|
||||||
|
subscribedBuckets: newSubscribed
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we just subscribed, we should fetch the tasks for this bucket immediately
|
||||||
|
if (!isSubscribed) {
|
||||||
|
const bucketName = store.buckets.find(b => b.id === bucketId)?.name;
|
||||||
|
if (bucketName) {
|
||||||
|
const records = await pb.collection(TASGRID_COLLECTION).getFullList({
|
||||||
|
filter: `tags ~ "${bucketName}"`,
|
||||||
|
sort: '-created'
|
||||||
|
});
|
||||||
|
|
||||||
|
const newTasks: Task[] = [];
|
||||||
|
records.forEach((r: any) => {
|
||||||
|
const exists = store.tasks.find(t => t.id === r.id);
|
||||||
|
if (!exists && !r.tags?.includes("__template__")) {
|
||||||
|
newTasks.push(mapRecordToTask(r));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newTasks.length > 0) {
|
||||||
|
setStore("tasks", t => [...newTasks, ...t]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update subscriptions:", err);
|
||||||
|
// Revert on error
|
||||||
|
setStore("subscribedBuckets", currentSubscribed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBucket = async (name: string, color: string, description?: string) => {
|
||||||
|
try {
|
||||||
|
await pb.collection(BUCKETS_COLLECTION).create({
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
toast.success("Bucket created");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create bucket:", err);
|
||||||
|
toast.error("Failed to create bucket");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateBucket = async (id: string, data: { name?: string, color?: string, description?: string }) => {
|
||||||
|
try {
|
||||||
|
await pb.collection(BUCKETS_COLLECTION).update(id, data);
|
||||||
|
toast.success("Bucket updated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update bucket:", err);
|
||||||
|
toast.error("Failed to update bucket");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteBucket = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await pb.collection(BUCKETS_COLLECTION).delete(id);
|
||||||
|
toast.success("Bucket deleted");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bucket:", err);
|
||||||
|
toast.error("Failed to delete bucket");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => {
|
export const addTask = async (title: string, options?: { dueDate?: Date | string, priority?: number, tags?: string[], content?: string, size?: number }) => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
|
|
||||||
@@ -1368,7 +1573,7 @@ export const splitTask = async (taskId: string, userId: string) => {
|
|||||||
|
|
||||||
// -- Share Rules (for tag-based and all-tasks sharing) --
|
// -- Share Rules (for tag-based and all-tasks sharing) --
|
||||||
|
|
||||||
const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
|
||||||
|
|
||||||
export const loadShareRules = async () => {
|
export const loadShareRules = async () => {
|
||||||
if (!pb.authStore.isValid) return;
|
if (!pb.authStore.isValid) return;
|
||||||
@@ -1508,3 +1713,5 @@ export const setupPersistence = () => {
|
|||||||
// Actually, index.tsx calls this. We can leave it empty or have it check auth.
|
// Actually, index.tsx calls this. We can leave it empty or have it check auth.
|
||||||
initStore();
|
initStore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+162
-2
@@ -1,8 +1,8 @@
|
|||||||
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, addShareRule, removeShareRule } from "@/store";
|
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload } from "lucide-solid";
|
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload, Box, Edit2, Archive } 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";
|
||||||
@@ -22,6 +22,7 @@ export const SettingsView: Component = () => {
|
|||||||
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
||||||
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
|
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
|
||||||
const [isImportOpen, setIsImportOpen] = createSignal(false);
|
const [isImportOpen, setIsImportOpen] = createSignal(false);
|
||||||
|
const [isBucketsOpen, setIsBucketsOpen] = createSignal(false);
|
||||||
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
|
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
|
||||||
const [shareUserId, setShareUserId] = createSignal("");
|
const [shareUserId, setShareUserId] = createSignal("");
|
||||||
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
|
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
|
||||||
@@ -292,6 +293,165 @@ export const SettingsView: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Buckets Manager */}
|
||||||
|
<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={() => setIsBucketsOpen(!isBucketsOpen())}
|
||||||
|
>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Box size={16} />
|
||||||
|
Buckets (Global Workspaces)
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-muted-foreground">Manage public task buckets and your subscriptions.</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
|
||||||
|
{isBucketsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={isBucketsOpen()}>
|
||||||
|
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
{/* Create Bucket */}
|
||||||
|
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||||
|
<h4 class="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket</h4>
|
||||||
|
<form
|
||||||
|
class="flex gap-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const nameInput = form.elements.namedItem('name') as HTMLInputElement;
|
||||||
|
const colorInput = form.elements.namedItem('color') as HTMLInputElement;
|
||||||
|
const descInput = form.elements.namedItem('desc') as HTMLInputElement;
|
||||||
|
|
||||||
|
if (nameInput.value) {
|
||||||
|
createBucket(nameInput.value, colorInput.value || "#6366f1", descInput.value);
|
||||||
|
nameInput.value = "";
|
||||||
|
descInput.value = "";
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="color"
|
||||||
|
type="color"
|
||||||
|
class="w-9 h-9 rounded-lg border border-input p-0.5 bg-background cursor-pointer"
|
||||||
|
value="#6366f1"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
class="flex h-9 w-full sm:w-48 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Bucket Name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="desc"
|
||||||
|
class="flex flex-1 h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
/>
|
||||||
|
<Button size="sm" type="submit" class="h-9 gap-2">
|
||||||
|
<Plus size={14} />
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List Buckets */}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={store.buckets} fallback={
|
||||||
|
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||||
|
No buckets found.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(bucket) => {
|
||||||
|
const isSubscribed = () => store.subscribedBuckets.includes(bucket.id);
|
||||||
|
const [isEditing, setIsEditing] = createSignal(false);
|
||||||
|
const [editName, setEditName] = createSignal(bucket.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3 group/bucket hover:bg-muted/30 transition-all">
|
||||||
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="w-2 h-8 rounded-full shrink-0"
|
||||||
|
style={{ "background-color": bucket.color }}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<Show when={isEditing()} fallback={
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="font-bold text-sm truncate">{bucket.name}</p>
|
||||||
|
<button onClick={() => setIsEditing(true)} class="opacity-0 group-hover/bucket:opacity-50 hover:opacity-100 transition-opacity">
|
||||||
|
<Edit2 size={10} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<input
|
||||||
|
value={editName()}
|
||||||
|
onInput={(e) => setEditName(e.currentTarget.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() });
|
||||||
|
setIsEditing(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
if (editName() !== bucket.name) updateBucket(bucket.id, { name: editName() });
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="h-6 w-full max-w-[150px] rounded border border-input bg-transparent px-1 text-sm"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<p class="text-[10px] text-muted-foreground truncate opacity-80">{bucket.description || "No description"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant={isSubscribed() ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
class={cn(
|
||||||
|
"h-8 text-xs gap-2 transition-all",
|
||||||
|
isSubscribed() ? "bg-primary/20 text-primary hover:bg-primary/30" : "opacity-70 group-hover/bucket:opacity-100"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleBucketSubscription(bucket.id)}
|
||||||
|
>
|
||||||
|
{isSubscribed() ? (
|
||||||
|
<>
|
||||||
|
<Archive size={12} class="rotate-180" />
|
||||||
|
Pinned
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Archive size={12} />
|
||||||
|
Pin
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover/bucket:opacity-100 transition-opacity"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Delete the bucket "${bucket.name}"? This cannot be undone and will affect all users.`)) {
|
||||||
|
deleteBucket(bucket.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</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