unstable sharing refactor
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
VITE_TASGRID_DATA_MODE=dev
|
||||||
@@ -5,8 +5,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 4001",
|
"dev": "vite --host 0.0.0.0 --port 4001",
|
||||||
|
"dev:devdata": "vite --host 0.0.0.0 --port 4001 --mode devdata",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"build:devdata": "tsc -b && vite build --mode devdata",
|
||||||
"serve": "vite preview --host 0.0.0.0 --port 4000",
|
"serve": "vite preview --host 0.0.0.0 --port 4000",
|
||||||
|
"serve:devdata": "vite preview --host 0.0.0.0 --port 4002 --mode devdata",
|
||||||
"---PM2 COMMANDS---": "------------------",
|
"---PM2 COMMANDS---": "------------------",
|
||||||
"status": "pm2 status",
|
"status": "pm2 status",
|
||||||
"logs": "pm2 logs tasgrid",
|
"logs": "pm2 logs tasgrid",
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ interface EmbedLayoutProps {
|
|||||||
export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
|
export const EmbedLayout: Component<EmbedLayoutProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
<div class="flex h-screen w-full min-w-0 bg-background overflow-hidden relative font-sans antialiased text-foreground">
|
||||||
<main class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-background">
|
<main
|
||||||
|
class="flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth bg-background"
|
||||||
|
style={{
|
||||||
|
"padding-bottom": "calc(8px + env(safe-area-inset-bottom, 0px))"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="w-full h-full mx-auto p-4">
|
<div class="w-full h-full mx-auto p-4">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -196,10 +196,15 @@ export const Layout: Component<LayoutProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<main class={cn(
|
<main
|
||||||
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
class={cn(
|
||||||
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
"flex-1 min-w-0 overflow-y-auto overflow-x-hidden relative scroll-smooth",
|
||||||
)}>
|
!store.isNotepadMode ? "pb-32 sm:pb-6" : ""
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
"padding-bottom": "calc(8px + env(safe-area-inset-bottom, 0px))"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class={cn(
|
<div class={cn(
|
||||||
"w-full h-full mx-auto",
|
"w-full h-full mx-auto",
|
||||||
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
|
!store.isNotepadMode ? "max-w-screen-2xl px-4 sm:px-8 lg:px-12 pt-16 sm:pt-20 pb-8 space-y-6 sm:space-y-8" : "max-w-screen-2xl md:px-8 lg:px-12 md:pt-20 md:pb-8 pt-14"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button } from "./ui/button";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
|
import { store, currentTaskContext, setCurrentTaskContext } from "@/store";
|
||||||
import { pb } from "@/lib/pocketbase";
|
import { pb } from "@/lib/pocketbase";
|
||||||
import { createResource, createSignal, createEffect } from "solid-js";
|
import { createSignal, createEffect } from "solid-js";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
icon: any;
|
icon: any;
|
||||||
@@ -121,7 +121,6 @@ export const ContextSwitcher: Component<{
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const currentUserId = pb.authStore.model?.id;
|
|
||||||
const [open, setOpen] = createSignal(false);
|
const [open, setOpen] = createSignal(false);
|
||||||
|
|
||||||
// Sync with parent if needed
|
// Sync with parent if needed
|
||||||
@@ -136,37 +135,37 @@ export const ContextSwitcher: Component<{
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [oversightUsers] = createResource(
|
|
||||||
() => [...new Set(store.shareRules.filter(r => r.targetUserId === currentUserId && r.type === 'all').map(r => r.ownerId))],
|
|
||||||
async (ownerIds) => {
|
|
||||||
if (ownerIds.length === 0) return [];
|
|
||||||
try {
|
|
||||||
// Fetch user names for these IDs in a single query
|
|
||||||
const filter = ownerIds.map(id => `id="${id}"`).join(' || ');
|
|
||||||
const users = await pb.collection('users').getFullList({
|
|
||||||
filter,
|
|
||||||
fields: 'id,name,email',
|
|
||||||
requestKey: null // Disable auto-cancellation
|
|
||||||
});
|
|
||||||
return users.map(u => ({ id: u.id, name: u.name || u.email }));
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.isAbort) return []; // Ignore explicit aborts
|
|
||||||
console.error("Failed to load oversight users", e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = currentTaskContext; // Accessor
|
const ctx = currentTaskContext; // Accessor
|
||||||
|
const personalContext = () =>
|
||||||
|
store.contexts.find(context =>
|
||||||
|
!context.deletedAt &&
|
||||||
|
context.kind === "user" &&
|
||||||
|
context.targetUserId === pb.authStore.model?.id
|
||||||
|
) || null;
|
||||||
|
const isPersonalContext = () => {
|
||||||
|
const current = ctx();
|
||||||
|
const personal = personalContext();
|
||||||
|
return current === 'mine' || (
|
||||||
|
typeof current === "object" &&
|
||||||
|
"bucketId" in current &&
|
||||||
|
!!personal &&
|
||||||
|
current.bucketId === personal.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const label = () => {
|
const label = () => {
|
||||||
const c = ctx();
|
const c = ctx();
|
||||||
return c === 'mine' ? "My Bucket" : c.name;
|
return isPersonalContext() ? "My Bucket" : (c === 'mine' ? "My Bucket" : c.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
const handleSwitch = (id: string, name: string, type: 'mine' | 'user' | 'bucket' = 'mine') => {
|
||||||
if (type === 'mine') {
|
if (type === 'mine') {
|
||||||
setCurrentTaskContext('mine');
|
const personal = personalContext();
|
||||||
|
if (personal) {
|
||||||
|
setCurrentTaskContext({ bucketId: personal.id, name: "My Bucket", isPersonal: true });
|
||||||
|
} else {
|
||||||
|
setCurrentTaskContext('mine');
|
||||||
|
}
|
||||||
} else if (type === 'bucket') {
|
} else if (type === 'bucket') {
|
||||||
setCurrentTaskContext({ bucketId: id, name });
|
setCurrentTaskContext({ bucketId: id, name });
|
||||||
} else {
|
} else {
|
||||||
@@ -182,7 +181,7 @@ export const ContextSwitcher: Component<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasContexts = () => (oversightUsers() && oversightUsers()!.length > 0) || store.subscribedBuckets.length > 0;
|
const hasContexts = () => !!personalContext() || store.subscribedBuckets.length > 0 || store.supervisedContexts.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={hasContexts()}>
|
<Show when={hasContexts()}>
|
||||||
@@ -197,9 +196,9 @@ export const ContextSwitcher: Component<{
|
|||||||
<Show when={props.isMobile} fallback={
|
<Show when={props.isMobile} fallback={
|
||||||
<>
|
<>
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class={cn(
|
<div class={cn(
|
||||||
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
"w-4 h-4 rounded-full flex items-center justify-center shrink-0",
|
||||||
ctx() === 'mine' ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
|
isPersonalContext() ? "bg-primary/20 text-primary" : "bg-orange-500/20 text-orange-500"
|
||||||
)}>
|
)}>
|
||||||
<div class="w-2 h-2 rounded-full bg-current" />
|
<div class="w-2 h-2 rounded-full bg-current" />
|
||||||
</div>
|
</div>
|
||||||
@@ -209,8 +208,8 @@ export const ContextSwitcher: Component<{
|
|||||||
</>
|
</>
|
||||||
}>
|
}>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", ctx() !== 'mine' && "text-orange-500")} />
|
<Users size={18} class={cn("transition-transform duration-300", open() && "scale-110", !isPersonalContext() && "text-orange-500")} />
|
||||||
{ctx() !== 'mine' && (
|
{!isPersonalContext() && (
|
||||||
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
|
<div class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-orange-500 border border-background" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +222,7 @@ export const ContextSwitcher: Component<{
|
|||||||
onClick={() => handleSwitch('mine', '', '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"
|
isPersonalContext() ? "bg-primary/10 text-primary font-semibold" : "hover:bg-muted text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
|
<div class="w-4 h-4 rounded-full bg-primary/20 flex items-center justify-center">
|
||||||
@@ -260,31 +259,32 @@ export const ContextSwitcher: Component<{
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={oversightUsers() && oversightUsers()!.length > 0}>
|
<Show when={store.supervisedContexts.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>
|
||||||
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
<div class="px-2 py-1 text-[0.625rem] font-bold uppercase text-muted-foreground tracking-wider flex items-center gap-2">
|
||||||
<span>Shared User Buckets</span>
|
<span>Supervised Contexts</span>
|
||||||
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{oversightUsers()?.length}</span>
|
<span class="ml-auto bg-muted px-1.5 rounded text-[0.5625rem]">{store.supervisedContexts.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<For each={oversightUsers()}>
|
<For each={store.supervisedContexts}>
|
||||||
{(user) => (
|
{(supervised) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSwitch(user.id, user.name, 'user')}
|
onClick={() => handleSwitch(supervised.ownerUserId, supervised.ownerName, '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 === supervised.ownerUserId ? "bg-orange-500/10 text-orange-600 font-semibold" : "hover:bg-muted text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
|
<div class="w-4 h-4 rounded-full bg-orange-500/20 flex items-center justify-center">
|
||||||
<div class="w-2 h-2 rounded-full bg-orange-500" />
|
<div class="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
</div>
|
</div>
|
||||||
<span class="truncate">{user.name}</span>
|
<span class="truncate">{supervised.ownerName}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export const QuickEntryForm: Component<QuickEntryFormProps> = (props) => {
|
|||||||
}
|
}
|
||||||
} else if (typeof ctx === 'object') {
|
} else if (typeof ctx === 'object') {
|
||||||
if ('bucketId' in ctx) {
|
if ('bucketId' in ctx) {
|
||||||
const bucketName = ctx.name;
|
const bucketName = store.contexts.find(context => context.id === ctx.bucketId)?.displayName || ctx.name;
|
||||||
if (bucketName) {
|
if (bucketName) {
|
||||||
const tag = `@${bucketName}`;
|
const tag = `@${bucketName}`;
|
||||||
if (!finalTags.includes(tag)) finalTags.push(tag);
|
if (!finalTags.includes(tag)) finalTags.push(tag);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useTheme } from "./ThemeProvider";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tag, Plus } from "lucide-solid";
|
import { Tag, Plus } from "lucide-solid";
|
||||||
|
import { toast } from "solid-sonner";
|
||||||
|
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey } from "@/lib/tags";
|
||||||
|
|
||||||
interface TagPickerProps {
|
interface TagPickerProps {
|
||||||
selectedTags: string[];
|
selectedTags: string[];
|
||||||
@@ -24,11 +26,11 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
|||||||
|
|
||||||
const allTagNames = () => {
|
const allTagNames = () => {
|
||||||
const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
|
const definedTags = store.tagDefinitions.filter(d => !d.name.endsWith("_favorite__")).map(d => d.name);
|
||||||
const bucketTags = store.buckets.map(b => `@${b.name}`);
|
const contextTags = store.contexts.filter(c => !c.deletedAt).map(c => buildShareTag(c.displayName));
|
||||||
const noteTags = store.notes
|
const noteTags = store.notes
|
||||||
.filter(n => !(n.tags || []).some(t => t.startsWith("child-of-")))
|
.filter(n => !(n.tags || []).some(t => t.startsWith("child-of-")))
|
||||||
.map(n => `#${n.title}`);
|
.map(n => buildNoteTag(n.key || n.title));
|
||||||
return [...new Set([...definedTags, ...bucketTags, ...noteTags])].sort();
|
return [...new Set([...definedTags, ...contextTags, ...noteTags])].sort();
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestedTags = () => {
|
const suggestedTags = () => {
|
||||||
@@ -53,10 +55,28 @@ export const TagPicker: Component<TagPickerProps> = (props) => {
|
|||||||
const name = selectedTagName().trim();
|
const name = selectedTagName().trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
|
||||||
|
if (name.startsWith("@")) {
|
||||||
|
const exists = store.contexts.some(context => context.key === normalizeContextKey(name));
|
||||||
|
if (!exists) {
|
||||||
|
toast.error("Create the shared context first before tagging tasks with it.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.startsWith("#")) {
|
||||||
|
const exists = store.notes.some(note => note.key === normalizeNoteKey(name));
|
||||||
|
if (!exists) {
|
||||||
|
toast.error("Create the canonical note first before linking it with a #tag.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update global definition
|
// update global definition
|
||||||
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
|
const bucket = store.buckets.find(b => b.name === name || `@${b.name}` === name);
|
||||||
const color = bucket ? bucket.color : undefined;
|
const color = bucket ? bucket.color : undefined;
|
||||||
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
|
if (!name.startsWith("@") && !name.startsWith("#")) {
|
||||||
|
upsertTagDefinition(name, valueScore(), color, resolvedTheme());
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle in current task selection if not already there
|
// Toggle in current task selection if not already there
|
||||||
const currentSelected = props.selectedTags;
|
const currentSelected = props.selectedTags;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getThemeAdjustedColor } from "@/lib/colors";
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||||
import { getStatusOptionsForTask } from "@/lib/constants";
|
import { getStatusOptionsForTask } from "@/lib/constants";
|
||||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
import { getRecurrenceProgress } from "@/lib/recurrence";
|
||||||
|
import { buildShareTag } from "@/lib/tags";
|
||||||
import { StatusCircle } from "./StatusCircle";
|
import { StatusCircle } from "./StatusCircle";
|
||||||
|
|
||||||
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
||||||
@@ -33,9 +34,8 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
|||||||
const ctx = currentTaskContext();
|
const ctx = currentTaskContext();
|
||||||
|
|
||||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||||
// Buckets are now tagged with '@' + name.
|
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase();
|
||||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
|
||||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,23 +139,13 @@ export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props)
|
|||||||
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
|
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shared Indicator - shows for individual shares OR tag-based ShareRules */}
|
{/* Shared Indicator - driven by @tag share contexts */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasIndividualShares = props.task.sharedWith && props.task.sharedWith.length > 0;
|
if (props.task.shareRefs.length === 0) return null;
|
||||||
// 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 tooltip = props.task.shareRefs.length === 1
|
||||||
|
? 'Shared via 1 @context'
|
||||||
const shareCount = (props.task.sharedWith?.length || 0);
|
: `Shared via ${props.task.shareRefs.length} @contexts`;
|
||||||
const tooltip = hasIndividualShares
|
|
||||||
? `Shared with ${shareCount} user${shareCount > 1 ? 's' : ''}`
|
|
||||||
: 'Shared via tag rule';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
|
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
|
||||||
|
|||||||
+33
-148
@@ -1,9 +1,9 @@
|
|||||||
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
|
import { type Component, createEffect, createSignal, For, createMemo, onCleanup, Show } from "solid-js";
|
||||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||||
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, loadTaskContent, currentTaskContext, cleanupTaskAttachments, getFavoriteTag, now } from "@/store";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { 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, Share2, UserMinus, GitBranch, Tag, Settings, Star, FileText } from "lucide-solid";
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Star, FileText } from "lucide-solid";
|
||||||
import { StatusCircle } from "./StatusCircle";
|
import { StatusCircle } from "./StatusCircle";
|
||||||
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
@@ -16,8 +16,8 @@ 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";
|
|
||||||
import { getRecurrenceProgress } from "@/lib/recurrence";
|
import { getRecurrenceProgress } from "@/lib/recurrence";
|
||||||
|
import { buildShareTag } from "@/lib/tags";
|
||||||
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
import { TextBubbleMenu } from "@/components/TextBubbleMenu";
|
||||||
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
import { TableBubbleMenu } from "@/components/TableBubbleMenu";
|
||||||
|
|
||||||
@@ -174,7 +174,8 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
|
|
||||||
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
|
// If in a bucket view, ensure the bucket tag is preserved (it was hidden from the UI so it's missing from 'tags')
|
||||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||||
const bucketTagName = `@${(ctx as { name: string }).name}`;
|
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||||
|
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name);
|
||||||
// Check case-insensitive existence
|
// Check case-insensitive existence
|
||||||
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
|
if (!newTags.some(t => t.toLowerCase() === bucketTagName.toLowerCase())) {
|
||||||
newTags.push(bucketTagName);
|
newTags.push(bucketTagName);
|
||||||
@@ -201,9 +202,8 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
const ctx = currentTaskContext();
|
const ctx = currentTaskContext();
|
||||||
|
|
||||||
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
if (typeof ctx === 'object' && 'bucketId' in ctx) {
|
||||||
// We are in a bucket view. Hide the tag that matches this bucket's name.
|
const bucketContext = store.contexts.find(context => context.id === ctx.bucketId);
|
||||||
// Buckets are now tagged with '@' + name.
|
const bucketTagName = buildShareTag(bucketContext?.displayName || ctx.name).toLowerCase();
|
||||||
const bucketTagName = `@${(ctx as { name: string }).name.toLowerCase()}`;
|
|
||||||
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
return tags.filter(t => t.toLowerCase() !== bucketTagName && !t.endsWith("_favorite__"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,11 +682,32 @@ export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|||||||
{/* Sharing Section */}
|
{/* Sharing Section */}
|
||||||
<div class="p-2 border-t border-border/50">
|
<div class="p-2 border-t border-border/50">
|
||||||
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
<div class="text-[0.625rem] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
||||||
<UserSharePicker
|
<div class="px-2 py-1.5 rounded-lg bg-muted/20 border border-border/30 space-y-2">
|
||||||
taskId={props.task.id}
|
<p class="text-[0.6875rem] text-muted-foreground leading-relaxed">
|
||||||
sharedWith={props.task.sharedWith || []}
|
Sharing is driven by the task's <span class="font-semibold text-foreground">@tags</span>. Add or remove
|
||||||
tags={props.task.tags || []}
|
<span class="font-semibold text-foreground"> @people</span> and <span class="font-semibold text-foreground">@buckets</span> in the tag list above.
|
||||||
/>
|
</p>
|
||||||
|
<Show
|
||||||
|
when={props.task.shareRefs.length > 0}
|
||||||
|
fallback={<p class="text-[0.6875rem] text-muted-foreground/70">No shared contexts on this task.</p>}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<For each={props.task.shareRefs}>
|
||||||
|
{(ref) => {
|
||||||
|
const context = store.contexts.find(existing => existing.id === ref.contextId) || store.contexts.find(existing => existing.key === ref.key);
|
||||||
|
const label = `@${context?.displayName || ref.key}`;
|
||||||
|
const policy = context?.policy || (ref.kind === "bucket" ? "handoff" : "collaborative");
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" class="h-6 px-2 text-[0.625rem] gap-1 bg-muted/40 border-border/40">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span class="uppercase text-[0.5rem] tracking-wider text-muted-foreground">{policy}</span>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -757,139 +778,3 @@ const MetadataItem: Component<{
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// User Share Picker Component
|
|
||||||
const UserSharePicker: Component<{
|
|
||||||
taskId: string;
|
|
||||||
sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>;
|
|
||||||
tags: string[];
|
|
||||||
}> = (props) => {
|
|
||||||
const [users, setUsers] = createSignal<Array<{ id: string; name: string }>>([]);
|
|
||||||
const [selectedUserId, setSelectedUserId] = createSignal<string>("");
|
|
||||||
const [loading, setLoading] = createSignal(true);
|
|
||||||
|
|
||||||
// Get matching ShareRules for this task
|
|
||||||
const matchingRules = () => {
|
|
||||||
const currentUserId = pb.authStore.model?.id;
|
|
||||||
return store.shareRules.filter(rule => {
|
|
||||||
if (rule.ownerId !== currentUserId) return false;
|
|
||||||
if (rule.type === 'all') return true;
|
|
||||||
if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true;
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getUserName = (userId: string) => {
|
|
||||||
return users().find(u => u.id === userId)?.name || userId;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch users on mount
|
|
||||||
createEffect(async () => {
|
|
||||||
try {
|
|
||||||
const records = await pb.collection('users').getFullList({
|
|
||||||
fields: 'id,name'
|
|
||||||
});
|
|
||||||
setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id })));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load users:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const availableUsers = () => {
|
|
||||||
const sharedUserIds = props.sharedWith.map(s => s.userId);
|
|
||||||
const currentUserId = pb.authStore.model?.id;
|
|
||||||
return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div class="flex gap-1 mb-2">
|
|
||||||
<Select<{ id: string; name: string }>
|
|
||||||
value={users().find(u => u.id === selectedUserId()) || null}
|
|
||||||
onChange={(user) => setSelectedUserId(user?.id || "")}
|
|
||||||
options={availableUsers()}
|
|
||||||
optionValue="id"
|
|
||||||
optionTextValue="name"
|
|
||||||
placeholder={loading() ? "Loading..." : "Select user..."}
|
|
||||||
itemComponent={(itemProps: any) => (
|
|
||||||
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SelectTrigger class="flex-1 h-8 text-xs">
|
|
||||||
<SelectValue<{ id: string; name: string }>>
|
|
||||||
{(state) => state.selectedOption()?.name || "Select user..."}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent />
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-8 w-8"
|
|
||||||
disabled={!selectedUserId()}
|
|
||||||
onClick={async () => {
|
|
||||||
if (selectedUserId()) {
|
|
||||||
await shareTask(props.taskId, selectedUserId(), 'edit');
|
|
||||||
setSelectedUserId("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Share2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<For each={props.sharedWith}>
|
|
||||||
{(share) => (
|
|
||||||
<div class="flex items-center justify-between py-1 px-2 rounded-lg bg-muted/30 mb-1">
|
|
||||||
<span class="text-xs truncate flex-1">{getUserName(share.userId)}</span>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-6 w-6 hover:bg-amber-500/10 hover:text-amber-600"
|
|
||||||
title="Split - Give them an independent copy"
|
|
||||||
onClick={() => splitTask(props.taskId, share.userId)}
|
|
||||||
>
|
|
||||||
<GitBranch size={12} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
class="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
title="Revoke access"
|
|
||||||
onClick={() => revokeShare(props.taskId, share.userId)}
|
|
||||||
>
|
|
||||||
<UserMinus size={12} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
|
|
||||||
{/* ShareRule-based sharing */}
|
|
||||||
{matchingRules().length > 0 && (
|
|
||||||
<div class="mt-3 pt-3 border-t border-border/30">
|
|
||||||
<div class="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40 mb-2 px-1">Automatic Rule Shares</div>
|
|
||||||
<For each={matchingRules()}>
|
|
||||||
{(rule) => (
|
|
||||||
<div class="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-muted/10 mb-1 border border-border/5">
|
|
||||||
<div class="shrink-0 text-muted-foreground/40">
|
|
||||||
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col min-w-0">
|
|
||||||
<span class="text-[0.6875rem] font-medium leading-none truncate">
|
|
||||||
{getUserName(rule.targetUserId)}
|
|
||||||
</span>
|
|
||||||
<span class="text-[9px] text-muted-foreground/50 mt-0.5 uppercase tracking-tighter">
|
|
||||||
{rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase();
|
||||||
|
|
||||||
|
export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod";
|
||||||
|
export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev";
|
||||||
|
|
||||||
|
export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro";
|
||||||
|
export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth";
|
||||||
|
export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid";
|
||||||
|
export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod";
|
||||||
|
|
||||||
|
export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base;
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { withDataSuffix } from "@/lib/app-config";
|
||||||
|
|
||||||
export const PRIORITY_OPTIONS = [
|
export const PRIORITY_OPTIONS = [
|
||||||
{ value: "10", label: "10 - Necessary" },
|
{ value: "10", label: "10 - Necessary" },
|
||||||
{ value: "9", label: "9" },
|
{ value: "9", label: "9" },
|
||||||
@@ -36,11 +38,12 @@ export const URGENCY_HOURS: Record<number, number> = {
|
|||||||
1: 4320
|
1: 4320
|
||||||
};
|
};
|
||||||
|
|
||||||
export const TASGRID_COLLECTION = 'TasGrid';
|
export const TASGRID_COLLECTION = withDataSuffix('TasGrid');
|
||||||
export const TAGS_COLLECTION = 'TasGrid_Tags';
|
export const CONTEXTS_COLLECTION = withDataSuffix('TasGrid_Contexts');
|
||||||
export const SHARE_RULES_COLLECTION = 'TasGrid_ShareRules';
|
export const NOTES_COLLECTION = withDataSuffix('TasGrid_Notes');
|
||||||
export const BUCKETS_COLLECTION = 'buckets';
|
export const TAGS_COLLECTION = CONTEXTS_COLLECTION;
|
||||||
export const NOTES_COLLECTION = 'TasGrid_Notes';
|
export const SHARE_RULES_COLLECTION = CONTEXTS_COLLECTION;
|
||||||
|
export const BUCKETS_COLLECTION = CONTEXTS_COLLECTION;
|
||||||
|
|
||||||
export const SIZE_OPTIONS = [
|
export const SIZE_OPTIONS = [
|
||||||
{ value: "10", label: "10 - Two weeks" },
|
{ value: "10", label: "10 - Two weeks" },
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { store } from "@/store";
|
|||||||
import { render } from "solid-js/web";
|
import { render } from "solid-js/web";
|
||||||
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
||||||
import { MentionList } from "@/components/MentionList";
|
import { MentionList } from "@/components/MentionList";
|
||||||
|
import { buildShareTag } from "@/lib/tags";
|
||||||
|
|
||||||
export const userSuggestion = {
|
export const userSuggestion = {
|
||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
return store.tagDefinitions
|
return store.contexts
|
||||||
.filter(tag => tag.isUser && tag.name.toLowerCase().substring(1).startsWith(query.toLowerCase()))
|
.filter(context => context.kind === "user" && !context.deletedAt)
|
||||||
.map(tag => ({
|
.filter(context => context.displayName.toLowerCase().startsWith(query.toLowerCase()))
|
||||||
id: tag.name, // Use the tag name as ID for sharing logic
|
.map(context => ({
|
||||||
label: tag.name.substring(1),
|
id: buildShareTag(context.displayName),
|
||||||
|
label: context.displayName,
|
||||||
type: 'user' as const
|
type: 'user' as const
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
@@ -90,10 +92,10 @@ export const noteSuggestion = {
|
|||||||
items: ({ query }: { query: string }) => {
|
items: ({ query }: { query: string }) => {
|
||||||
return store.notes
|
return store.notes
|
||||||
.filter(note => !(note.tags || []).some(t => t.startsWith("child-of-")))
|
.filter(note => !(note.tags || []).some(t => t.startsWith("child-of-")))
|
||||||
.filter(note => note.title.toLowerCase().includes(query.toLowerCase()))
|
.filter(note => (note.key || note.title).toLowerCase().includes(query.toLowerCase()))
|
||||||
.map(note => ({
|
.map(note => ({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
label: note.title,
|
label: note.key || note.title,
|
||||||
sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''),
|
sublabel: note.content?.substring(0, 50).replace(/<[^>]*>?/gm, ''),
|
||||||
type: 'note' as const
|
type: 'note' as const
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import PocketBase from 'pocketbase';
|
import PocketBase, { LocalAuthStore } from 'pocketbase';
|
||||||
|
import { AUTH_STORE_KEY, POCKETBASE_URL } from "@/lib/app-config";
|
||||||
|
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION } from "@/lib/constants";
|
||||||
|
|
||||||
export const pb = new PocketBase('https://pocketbase.ccllc.pro');
|
export const pb = new PocketBase(POCKETBASE_URL, new LocalAuthStore(AUTH_STORE_KEY));
|
||||||
|
export { TASGRID_COLLECTION, CONTEXTS_COLLECTION, NOTES_COLLECTION };
|
||||||
export const TASGRID_COLLECTION = 'TasGrid';
|
|
||||||
export const TAGS_COLLECTION = 'TasGrid_Tags';
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export type ParsedTag =
|
||||||
|
| { kind: "share"; raw: string; key: string; display: string }
|
||||||
|
| { kind: "note"; raw: string; key: string; display: string }
|
||||||
|
| { kind: "label"; raw: string; key: string; display: string };
|
||||||
|
|
||||||
|
const normalizeWhitespace = (value: string) => value.trim().replace(/\s+/g, " ");
|
||||||
|
|
||||||
|
export const normalizeKey = (value: string) =>
|
||||||
|
normalizeWhitespace(value)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^[@#]+/, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "");
|
||||||
|
|
||||||
|
export const normalizeContextKey = (value: string) => normalizeKey(value);
|
||||||
|
export const normalizeNoteKey = (value: string) => normalizeKey(value);
|
||||||
|
|
||||||
|
export const parseTag = (rawTag: string): ParsedTag => {
|
||||||
|
const raw = normalizeWhitespace(rawTag);
|
||||||
|
const prefix = raw[0];
|
||||||
|
const display = prefix === "@" || prefix === "#" ? raw.slice(1).trim() : raw;
|
||||||
|
const key = normalizeKey(display);
|
||||||
|
|
||||||
|
if (prefix === "@") {
|
||||||
|
return { kind: "share", raw, key, display };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prefix === "#") {
|
||||||
|
return { kind: "note", raw, key, display };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { kind: "label", raw, key, display };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseTags = (rawTags: string[] | undefined | null) =>
|
||||||
|
(rawTags || [])
|
||||||
|
.map(parseTag)
|
||||||
|
.filter(tag => !!tag.display);
|
||||||
|
|
||||||
|
export const buildShareTag = (displayName: string) => `@${normalizeWhitespace(displayName)}`;
|
||||||
|
export const buildNoteTag = (keyOrTitle: string) => `#${normalizeWhitespace(keyOrTitle)}`;
|
||||||
+875
-924
File diff suppressed because it is too large
Load Diff
@@ -739,7 +739,13 @@ export const NotepadView: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Sticky Footer Actions (Match TaskDetail style) */}
|
{/* Sticky Footer Actions (Match TaskDetail style) */}
|
||||||
<div class="px-4 py-4 border-t border-border/50 md:hidden flex items-center justify-between shrink-0 bg-background/95 backdrop-blur-sm z-40 w-full sticky bottom-0">
|
<div
|
||||||
|
class="px-4 pt-4 border-t border-border/50 md:hidden flex items-center justify-between shrink-0 bg-background/95 backdrop-blur-sm z-40 w-full sticky bottom-0"
|
||||||
|
style={{
|
||||||
|
"padding-bottom": "calc(1rem + env(safe-area-inset-bottom, 0px))",
|
||||||
|
"min-height": "calc(73px + env(safe-area-inset-bottom, 0px))"
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2 w-16 shrink-0">
|
<div class="flex items-center gap-2 w-16 shrink-0">
|
||||||
{/* "..." popover menu containing Public/Private toggle and Delete */}
|
{/* "..." popover menu containing Public/Private toggle and Delete */}
|
||||||
<Show when={isOwner()}>
|
<Show when={isOwner()}>
|
||||||
|
|||||||
+141
-18
@@ -1,6 +1,6 @@
|
|||||||
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, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription } from "@/store";
|
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors } from "@/store";
|
||||||
import { useTheme } from "@/components/ThemeProvider";
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
|
||||||
import { TagPicker } from "@/components/TagPicker";
|
import { TagPicker } from "@/components/TagPicker";
|
||||||
@@ -25,13 +25,12 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
const [isBucketsOpen, setIsBucketsOpen] = 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'>('tag');
|
||||||
const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER');
|
const [shareMode, setShareMode] = createSignal<'ADD_USER' | 'SEND_TASK'>('ADD_USER');
|
||||||
const [shareTag, setShareTag] = createSignal<string>("");
|
const [shareTag, setShareTag] = createSignal<string>("");
|
||||||
const [allUsers, setAllUsers] = createSignal<Array<{ id: string; name: string }>>([]);
|
const [allUsers, setAllUsers] = createSignal<Array<{ id: string; name: string }>>([]);
|
||||||
const [usersLoading, setUsersLoading] = createSignal(false);
|
const [usersLoading, setUsersLoading] = createSignal(false);
|
||||||
|
|
||||||
// Fetch users when sharing section opens
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) {
|
if (isSharingOpen() && allUsers().length === 0 && !usersLoading()) {
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
@@ -53,6 +52,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
return allUsers().filter(u => u.id !== currentUserId);
|
return allUsers().filter(u => u.id !== currentUserId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userContexts = () =>
|
||||||
|
store.contexts
|
||||||
|
.filter(context => !context.deletedAt && context.kind === "user")
|
||||||
|
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
|
||||||
return (
|
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">
|
||||||
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
@@ -113,9 +117,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
<Share2 size={16} />
|
<Share2 size={16} />
|
||||||
Task Sharing
|
Tag Sharing
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-muted-foreground">Share all tasks or tasks with specific tags with others.</p>
|
<p class="text-sm text-muted-foreground">Share through tags only: `@people` collaborate, `@buckets` route work, and `#notes` stay link-only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
|
<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} />}
|
{isSharingOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
@@ -124,6 +128,112 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
|
|
||||||
<Show when={isSharingOpen()}>
|
<Show when={isSharingOpen()}>
|
||||||
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
|
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||||
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">User Tag Policies</h4>
|
||||||
|
<p class="text-[0.6875rem] text-muted-foreground">
|
||||||
|
Control how each <span class="font-semibold text-foreground">@user</span> tag behaves. Collaborative tags keep work shared. Handoff tags route work into that user's queue.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<For each={userContexts()} fallback={
|
||||||
|
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
|
||||||
|
No user contexts found yet.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(context) => (
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3 rounded-xl border border-border/40 bg-background/70">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-bold truncate">@{context.displayName}</p>
|
||||||
|
<p class="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{context.targetUserId === pb.authStore.model?.id ? "Your personal context" : `User: ${getUserName(context.targetUserId || "")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant={context.policy === 'collaborative' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-[0.625rem] gap-1 px-2"
|
||||||
|
onClick={() => updateUserContextPolicy(context.id, 'collaborative')}
|
||||||
|
>
|
||||||
|
<Users size={10} />
|
||||||
|
Collaborate
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={context.policy === 'handoff' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-[0.625rem] gap-1 px-2"
|
||||||
|
onClick={() => updateUserContextPolicy(context.id, 'handoff')}
|
||||||
|
>
|
||||||
|
<ArrowLeftRight size={10} />
|
||||||
|
Handoff
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||||
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Personal Context Supervision</h4>
|
||||||
|
<p class="text-[0.6875rem] text-muted-foreground">
|
||||||
|
Allow specific supervisors to switch into your whole personal context, the same way they switch into pinned bucket contexts.
|
||||||
|
</p>
|
||||||
|
<For each={availableUsers()} fallback={
|
||||||
|
<div class="text-center py-4 text-muted-foreground text-[0.625rem] uppercase tracking-widest italic border border-dashed border-border/40 rounded-xl bg-muted/5">
|
||||||
|
No other users available.
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{(user) => {
|
||||||
|
const hasAccess = () => store.personalContextSupervisorIds.includes(user.id);
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-between gap-3 p-3 rounded-xl border border-border/40 bg-background/70">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-sm font-bold truncate">{user.name}</p>
|
||||||
|
<p class="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||||
|
{hasAccess() ? "Can switch into your personal context" : "No supervision access"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={hasAccess() ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-[0.625rem] px-3"
|
||||||
|
onClick={() => {
|
||||||
|
const nextIds = hasAccess()
|
||||||
|
? store.personalContextSupervisorIds.filter(id => id !== user.id)
|
||||||
|
: [...store.personalContextSupervisorIds, user.id];
|
||||||
|
savePersonalContextSupervisors(nextIds);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasAccess() ? "Allowed" : "Grant Access"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={store.supervisedContexts.length > 0}>
|
||||||
|
<div class="space-y-3 pt-2">
|
||||||
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground pb-1 flex items-center gap-2">
|
||||||
|
<div class="w-1 h-1 rounded-full bg-orange-500" />
|
||||||
|
Personal Contexts Shared With You
|
||||||
|
</h4>
|
||||||
|
<For each={store.supervisedContexts}>
|
||||||
|
{(context) => (
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-xl bg-muted/20 border border-border/40 gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-xs font-bold truncate">{context.ownerName}</p>
|
||||||
|
<p class="text-[0.5625rem] text-muted-foreground uppercase font-black tracking-widest opacity-60">
|
||||||
|
Available in the context switcher
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={false}>
|
||||||
{/* Add new share rule */}
|
{/* Add new share rule */}
|
||||||
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -131,10 +241,11 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
variant={shareType() === 'all' ? 'default' : 'outline'}
|
variant={shareType() === 'all' ? 'default' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 text-xs gap-1"
|
class="h-8 text-xs gap-1"
|
||||||
onClick={() => setShareType('all')}
|
disabled
|
||||||
|
title="All-task sharing is no longer supported"
|
||||||
>
|
>
|
||||||
<Users size={12} />
|
<Users size={12} />
|
||||||
All Tasks
|
Legacy Off
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={shareType() === 'tag' ? 'default' : 'outline'}
|
variant={shareType() === 'tag' ? 'default' : 'outline'}
|
||||||
@@ -188,7 +299,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
options={store.tagDefinitions.map(t => t.name)}
|
options={store.tagDefinitions.filter(t => t.name.startsWith("@")).map(t => t.name)}
|
||||||
placeholder="Select a tag"
|
placeholder="Select a tag"
|
||||||
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue}</SelectItem>}
|
||||||
>
|
>
|
||||||
@@ -221,10 +332,10 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-9 gap-2"
|
class="h-9 gap-2"
|
||||||
disabled={!shareUserId()}
|
disabled={!shareUserId() || !shareTag()}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (shareUserId()) {
|
if (shareUserId() && shareTag()) {
|
||||||
await addShareRule(shareType(), shareUserId(), shareType() === 'tag' ? shareTag() : undefined, shareMode());
|
await addShareRule('tag', shareUserId(), shareTag(), shareMode());
|
||||||
setShareUserId("");
|
setShareUserId("");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@@ -235,8 +346,8 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-[0.625rem] text-muted-foreground italic px-1 pt-1">
|
<p class="text-[0.625rem] text-muted-foreground italic px-1 pt-1">
|
||||||
{shareMode() === 'ADD_USER'
|
{shareMode() === 'ADD_USER'
|
||||||
? "User will be added to the task's shared list."
|
? "Collaborative contexts add access through the selected @tag."
|
||||||
: "Task owner will change to the recipient immediately when tagged."}
|
: "Handoff contexts transfer ownership when that @tag is applied."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -378,6 +489,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</section>
|
</section>
|
||||||
@@ -392,9 +504,9 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<h3 class="text-base font-semibold flex items-center gap-2">
|
<h3 class="text-base font-semibold flex items-center gap-2">
|
||||||
<Box size={16} />
|
<Box size={16} />
|
||||||
Shared Global Buckets
|
Shared Buckets
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-muted-foreground">Manage public task buckets and your subscriptions.</p>
|
<p class="text-sm text-muted-foreground">Each bucket is a real `@context` with a policy, metadata, soft delete, and personal pinning.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
|
<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} />}
|
{isBucketsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
@@ -405,7 +517,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
<div class="space-y-4 pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
|
||||||
{/* Create Bucket */}
|
{/* Create Bucket */}
|
||||||
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
|
||||||
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket</h4>
|
<h4 class="text-[0.625rem] font-black uppercase tracking-widest text-muted-foreground">Create New Bucket Context</h4>
|
||||||
<form
|
<form
|
||||||
class="flex flex-col sm:flex-row gap-2"
|
class="flex flex-col sm:flex-row gap-2"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -455,7 +567,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<For each={store.buckets} fallback={
|
<For each={store.buckets} fallback={
|
||||||
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
<div class="text-center py-8 text-muted-foreground text-sm italic border border-dashed border-border rounded-xl">
|
||||||
No buckets found.
|
No bucket contexts found.
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
{(bucket) => {
|
{(bucket) => {
|
||||||
@@ -497,10 +609,21 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<p class="text-[0.625rem] text-muted-foreground truncate opacity-80">{bucket.description || "No description"}</p>
|
<p class="text-[0.625rem] text-muted-foreground truncate opacity-80">{bucket.description || "No description"}</p>
|
||||||
|
<p class="text-[0.5625rem] uppercase tracking-widest text-muted-foreground/60 mt-1">@{bucket.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="h-8 text-[0.625rem] uppercase tracking-wider"
|
||||||
|
onClick={() => updateBucket(bucket.id, {
|
||||||
|
policy: bucket.policy === "handoff" ? "collaborative" : "handoff"
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{bucket.policy === "handoff" ? "Handoff" : "Collaborative"}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={isSubscribed() ? "secondary" : "outline"}
|
variant={isSubscribed() ? "secondary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -528,7 +651,7 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
|
|||||||
size="icon"
|
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"
|
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={() => {
|
onClick={() => {
|
||||||
if (confirm(`Delete the bucket "${bucket.name}" ? This cannot be undone and will affect all users.`)) {
|
if (confirm(`Archive the bucket "${bucket.name}"? Tasks stay intact and the bucket can be restored later.`)) {
|
||||||
deleteBucket(bucket.id);
|
deleteBucket(bucket.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+82
-72
@@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import solid from 'vite-plugin-solid'
|
import solid from 'vite-plugin-solid'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
@@ -9,80 +9,90 @@ const packageJson = JSON.parse(
|
|||||||
readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
|
readFileSync(new URL('./package.json', import.meta.url), 'utf-8')
|
||||||
);
|
);
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
define: {
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
__APP_VERSION__: JSON.stringify(packageJson.version),
|
const dataMode = (env.VITE_TASGRID_DATA_MODE || 'prod').toLowerCase()
|
||||||
},
|
const isDevData = dataMode === 'dev'
|
||||||
plugins: [
|
const pocketbaseUrl = env.VITE_POCKETBASE_URL || 'https://pocketbase.ccllc.pro'
|
||||||
solid(),
|
const pocketbaseOrigin = new URL(pocketbaseUrl).origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
tailwindcss(),
|
const cacheSuffix = isDevData ? '-devdata' : '-prod'
|
||||||
VitePWA({
|
|
||||||
registerType: 'prompt',
|
return {
|
||||||
includeAssets: ['icon.webp', 'vite.svg'],
|
define: {
|
||||||
manifest: {
|
__APP_VERSION__: JSON.stringify(packageJson.version),
|
||||||
name: 'Tasgrid',
|
},
|
||||||
short_name: 'Tasgrid',
|
plugins: [
|
||||||
description: 'Precision Productivity Task Manager',
|
solid(),
|
||||||
theme_color: '#0a0a0a',
|
tailwindcss(),
|
||||||
background_color: '#0a0a0a',
|
VitePWA({
|
||||||
display: 'standalone',
|
registerType: 'prompt',
|
||||||
icons: [
|
includeAssets: ['icon.webp', 'vite.svg'],
|
||||||
{
|
manifest: {
|
||||||
src: 'icon.webp',
|
name: isDevData ? 'Tasgrid Dev' : 'Tasgrid',
|
||||||
sizes: '1024x1024',
|
short_name: isDevData ? 'TasgridDev' : 'Tasgrid',
|
||||||
type: 'image/webp',
|
description: isDevData ? 'Tasgrid development data lane' : 'Precision Productivity Task Manager',
|
||||||
purpose: 'any'
|
theme_color: '#0a0a0a',
|
||||||
},
|
background_color: '#0a0a0a',
|
||||||
{
|
display: 'standalone',
|
||||||
src: 'icon.webp',
|
icons: [
|
||||||
sizes: '192x192',
|
{
|
||||||
type: 'image/webp',
|
src: 'icon.webp',
|
||||||
purpose: 'maskable'
|
sizes: '1024x1024',
|
||||||
},
|
type: 'image/webp',
|
||||||
{
|
purpose: 'any'
|
||||||
src: 'icon.webp',
|
},
|
||||||
sizes: '512x512',
|
{
|
||||||
type: 'image/webp',
|
src: 'icon.webp',
|
||||||
purpose: 'any'
|
sizes: '192x192',
|
||||||
}
|
type: 'image/webp',
|
||||||
]
|
purpose: 'maskable'
|
||||||
},
|
},
|
||||||
workbox: {
|
{
|
||||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
src: 'icon.webp',
|
||||||
runtimeCaching: [
|
sizes: '512x512',
|
||||||
{
|
type: 'image/webp',
|
||||||
urlPattern: /^https:\/\/pocketbase\.ccllc\.pro\/api\/.*/i,
|
purpose: 'any'
|
||||||
method: 'GET',
|
}
|
||||||
handler: 'NetworkFirst',
|
]
|
||||||
options: {
|
},
|
||||||
cacheName: 'api-cache',
|
workbox: {
|
||||||
expiration: {
|
cleanupOutdatedCaches: true,
|
||||||
maxEntries: 100,
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
maxAgeSeconds: 60 * 60 * 24 * 7 // 1 week
|
runtimeCaching: [
|
||||||
},
|
{
|
||||||
cacheableResponse: {
|
urlPattern: new RegExp(`^${pocketbaseOrigin}/api/.*`, 'i'),
|
||||||
statuses: [0, 200]
|
method: 'GET',
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: `api-cache${cacheSuffix}`,
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 100,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24 * 7
|
||||||
|
},
|
||||||
|
cacheableResponse: {
|
||||||
|
statuses: [0, 200]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
}
|
})
|
||||||
})
|
],
|
||||||
],
|
server: {
|
||||||
server: {
|
host: '0.0.0.0',
|
||||||
host: '0.0.0.0',
|
port: 4000,
|
||||||
port: 4000,
|
cors: { origin: '*' },
|
||||||
cors: { origin: '*' },
|
headers: {
|
||||||
headers: {
|
'Access-Control-Allow-Origin': '*'
|
||||||
'Access-Control-Allow-Origin': '*'
|
},
|
||||||
|
allowedHosts: [
|
||||||
|
'tasgrid.ccllc.pro'
|
||||||
|
]
|
||||||
},
|
},
|
||||||
allowedHosts: [
|
resolve: {
|
||||||
'tasgrid.ccllc.pro'
|
alias: {
|
||||||
]
|
"@": path.resolve(__dirname, "./src")
|
||||||
},
|
}
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user